sentinelayer-cli 0.8.0 → 0.8.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 (153) hide show
  1. package/README.md +23 -2
  2. package/package.json +4 -4
  3. package/src/agents/ai-governance/index.js +12 -0
  4. package/src/agents/ai-governance/tools/base.js +171 -0
  5. package/src/agents/ai-governance/tools/eval-regression.js +47 -0
  6. package/src/agents/ai-governance/tools/hitl-audit.js +81 -0
  7. package/src/agents/ai-governance/tools/index.js +52 -0
  8. package/src/agents/ai-governance/tools/prompt-drift.js +42 -0
  9. package/src/agents/ai-governance/tools/provenance-check.js +69 -0
  10. package/src/agents/backend/index.js +12 -0
  11. package/src/agents/backend/tools/base.js +189 -0
  12. package/src/agents/backend/tools/circuit-breaker-check.js +123 -0
  13. package/src/agents/backend/tools/idempotency-audit.js +105 -0
  14. package/src/agents/backend/tools/index.js +87 -0
  15. package/src/agents/backend/tools/retry-audit.js +132 -0
  16. package/src/agents/backend/tools/timeout-audit.js +144 -0
  17. package/src/agents/code-quality/index.js +12 -0
  18. package/src/agents/code-quality/tools/base.js +159 -0
  19. package/src/agents/code-quality/tools/complexity-measure.js +197 -0
  20. package/src/agents/code-quality/tools/coupling-analysis.js +81 -0
  21. package/src/agents/code-quality/tools/cycle-detect.js +49 -0
  22. package/src/agents/code-quality/tools/dep-graph.js +196 -0
  23. package/src/agents/code-quality/tools/index.js +89 -0
  24. package/src/agents/data-layer/index.js +12 -0
  25. package/src/agents/data-layer/tools/base.js +181 -0
  26. package/src/agents/data-layer/tools/index-audit.js +165 -0
  27. package/src/agents/data-layer/tools/index.js +83 -0
  28. package/src/agents/data-layer/tools/migration-scan.js +135 -0
  29. package/src/agents/data-layer/tools/query-explain.js +120 -0
  30. package/src/agents/data-layer/tools/tenancy-scan.js +166 -0
  31. package/src/agents/documentation/index.js +12 -0
  32. package/src/agents/documentation/tools/api-diff.js +91 -0
  33. package/src/agents/documentation/tools/base.js +151 -0
  34. package/src/agents/documentation/tools/dead-link-check.js +58 -0
  35. package/src/agents/documentation/tools/docstring-coverage.js +78 -0
  36. package/src/agents/documentation/tools/index.js +52 -0
  37. package/src/agents/documentation/tools/readme-freshness.js +61 -0
  38. package/src/agents/envelope/fix-cycle.js +45 -0
  39. package/src/agents/envelope/index.js +31 -0
  40. package/src/agents/envelope/loop.js +150 -0
  41. package/src/agents/envelope/pulse.js +18 -0
  42. package/src/agents/envelope/stream.js +40 -0
  43. package/src/agents/infrastructure/index.js +12 -0
  44. package/src/agents/infrastructure/tools/base.js +171 -0
  45. package/src/agents/infrastructure/tools/checkov-run.js +32 -0
  46. package/src/agents/infrastructure/tools/drift-detect.js +59 -0
  47. package/src/agents/infrastructure/tools/iam-least-priv-check.js +78 -0
  48. package/src/agents/infrastructure/tools/index.js +52 -0
  49. package/src/agents/infrastructure/tools/tflint-run.js +31 -0
  50. package/src/agents/jules/loop.js +7 -4
  51. package/src/agents/jules/swarm/sub-agent.js +5 -1
  52. package/src/agents/jules/tools/auth-audit.js +10 -1
  53. package/src/agents/mode.js +113 -0
  54. package/src/agents/observability/index.js +12 -0
  55. package/src/agents/observability/tools/alert-audit.js +39 -0
  56. package/src/agents/observability/tools/base.js +181 -0
  57. package/src/agents/observability/tools/dashboard-gap.js +42 -0
  58. package/src/agents/observability/tools/index.js +54 -0
  59. package/src/agents/observability/tools/log-schema-check.js +74 -0
  60. package/src/agents/observability/tools/span-coverage.js +74 -0
  61. package/src/agents/persona-visuals.js +38 -0
  62. package/src/agents/release/index.js +12 -0
  63. package/src/agents/release/tools/base.js +181 -0
  64. package/src/agents/release/tools/changelog-diff.js +86 -0
  65. package/src/agents/release/tools/feature-flag-audit.js +126 -0
  66. package/src/agents/release/tools/index.js +61 -0
  67. package/src/agents/release/tools/rollback-verify.js +129 -0
  68. package/src/agents/release/tools/semver-check.js +109 -0
  69. package/src/agents/reliability/index.js +12 -0
  70. package/src/agents/reliability/tools/backpressure-check.js +129 -0
  71. package/src/agents/reliability/tools/base.js +181 -0
  72. package/src/agents/reliability/tools/chaos-probe.js +109 -0
  73. package/src/agents/reliability/tools/graceful-degradation-check.js +114 -0
  74. package/src/agents/reliability/tools/health-check-audit.js +111 -0
  75. package/src/agents/reliability/tools/index.js +87 -0
  76. package/src/agents/run-persona.js +109 -0
  77. package/src/agents/security/index.js +12 -0
  78. package/src/agents/security/tools/authz-audit.js +134 -0
  79. package/src/agents/security/tools/base.js +190 -0
  80. package/src/agents/security/tools/crypto-review.js +175 -0
  81. package/src/agents/security/tools/index.js +97 -0
  82. package/src/agents/security/tools/sast-scan.js +175 -0
  83. package/src/agents/security/tools/secrets-scan.js +216 -0
  84. package/src/agents/supply-chain/index.js +12 -0
  85. package/src/agents/supply-chain/tools/attestation-check.js +42 -0
  86. package/src/agents/supply-chain/tools/base.js +151 -0
  87. package/src/agents/supply-chain/tools/index.js +52 -0
  88. package/src/agents/supply-chain/tools/lockfile-integrity.js +73 -0
  89. package/src/agents/supply-chain/tools/package-verify.js +56 -0
  90. package/src/agents/supply-chain/tools/sbom-diff.js +34 -0
  91. package/src/agents/testing/index.js +12 -0
  92. package/src/agents/testing/tools/base.js +202 -0
  93. package/src/agents/testing/tools/coverage-gap.js +144 -0
  94. package/src/agents/testing/tools/flake-detect.js +125 -0
  95. package/src/agents/testing/tools/index.js +85 -0
  96. package/src/agents/testing/tools/mutation-test.js +143 -0
  97. package/src/agents/testing/tools/snapshot-diff.js +103 -0
  98. package/src/auth/gate.js +65 -37
  99. package/src/cli.js +1 -1
  100. package/src/commands/chat.js +3 -10
  101. package/src/commands/legacy-args.js +10 -0
  102. package/src/commands/omargate.js +36 -2
  103. package/src/commands/persona.js +46 -1
  104. package/src/commands/scan.js +3 -10
  105. package/src/commands/session.js +654 -6
  106. package/src/commands/spec.js +3 -10
  107. package/src/coord/events-log.js +141 -0
  108. package/src/coord/handshake.js +719 -0
  109. package/src/coord/index.js +35 -0
  110. package/src/coord/paths.js +84 -0
  111. package/src/coord/priority.js +62 -0
  112. package/src/coord/tarjan.js +157 -0
  113. package/src/cost/tokenizer.js +160 -0
  114. package/src/cost/tracker.js +61 -0
  115. package/src/daemon/artifact-lineage.js +362 -0
  116. package/src/daemon/assignment-ledger.js +117 -0
  117. package/src/daemon/ast-drift.js +496 -0
  118. package/src/daemon/ingest-refresh.js +69 -2
  119. package/src/ingest/engine.js +15 -0
  120. package/src/ingest/ownership.js +380 -0
  121. package/src/legacy-cli.js +68 -1
  122. package/src/orchestrator/kai-chen.js +126 -0
  123. package/src/review/ai-review.js +3 -10
  124. package/src/review/compliance-pack.js +389 -0
  125. package/src/review/investor-dd-config.js +54 -0
  126. package/src/review/investor-dd-file-loop.js +303 -0
  127. package/src/review/investor-dd-file-router.js +406 -0
  128. package/src/review/investor-dd-html-report.js +233 -0
  129. package/src/review/investor-dd-notification.js +120 -0
  130. package/src/review/investor-dd-orchestrator.js +405 -0
  131. package/src/review/investor-dd-persona-runner.js +275 -0
  132. package/src/review/live-validator.js +253 -0
  133. package/src/review/omargate-orchestrator.js +90 -2
  134. package/src/review/persona-prompts.js +244 -56
  135. package/src/review/reconciliation-rules.js +329 -0
  136. package/src/review/reproducibility-chain.js +136 -0
  137. package/src/review/scan-modes.js +102 -3
  138. package/src/session/agent-registry.js +7 -0
  139. package/src/session/analytics.js +479 -0
  140. package/src/session/daemon.js +609 -14
  141. package/src/session/file-locks.js +666 -0
  142. package/src/session/paths.js +4 -0
  143. package/src/session/recap.js +567 -0
  144. package/src/session/redact.js +82 -0
  145. package/src/session/runtime-bridge.js +24 -1
  146. package/src/session/scoring.js +406 -0
  147. package/src/session/setup-guides.js +304 -0
  148. package/src/session/store.js +318 -2
  149. package/src/session/stream.js +9 -1
  150. package/src/session/sync.js +753 -0
  151. package/src/session/tasks.js +1054 -0
  152. package/src/session/templates.js +188 -0
  153. package/src/swarm/runtime.js +1 -8
@@ -0,0 +1,567 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+
5
+ import { createAgentEvent } from "../events/schema.js";
6
+ import { resolveSessionPaths } from "./paths.js";
7
+ import { appendToStream, readStream } from "./stream.js";
8
+
9
+ const SENTI_AGENT_ID = "senti";
10
+ const SENTI_MODEL = "gpt-5.4-mini";
11
+ const RECAP_STYLE = "italic-grey";
12
+ const DEFAULT_RECAP_MAX_EVENTS = 100;
13
+ const DEFAULT_RECAP_INTERVAL_MS = 300_000;
14
+ const DEFAULT_RECAP_INACTIVITY_MS = 600_000;
15
+ const DEFAULT_RECAP_ACTIVITY_THRESHOLD = 5;
16
+
17
+ const ACTIVE_RECAP_EMITTERS = new Map();
18
+
19
+ function normalizeString(value) {
20
+ return String(value || "").trim();
21
+ }
22
+
23
+ function normalizeIsoTimestamp(value, fallbackIso = new Date().toISOString()) {
24
+ const normalized = normalizeString(value);
25
+ if (!normalized) {
26
+ return fallbackIso;
27
+ }
28
+ const epoch = Date.parse(normalized);
29
+ if (!Number.isFinite(epoch)) {
30
+ return fallbackIso;
31
+ }
32
+ return new Date(epoch).toISOString();
33
+ }
34
+
35
+ function normalizePositiveInteger(value, fallbackValue) {
36
+ const normalized = Number(value);
37
+ if (!Number.isFinite(normalized) || normalized <= 0) {
38
+ return fallbackValue;
39
+ }
40
+ return Math.max(1, Math.floor(normalized));
41
+ }
42
+
43
+ function toEpoch(value, fallbackIso = new Date().toISOString()) {
44
+ return Date.parse(normalizeIsoTimestamp(value, fallbackIso)) || 0;
45
+ }
46
+
47
+ function isRecapEvent(event = {}) {
48
+ const eventName = normalizeString(event.event).toLowerCase();
49
+ if (eventName === "context_briefing" || eventName === "session_recap") {
50
+ return true;
51
+ }
52
+ const payload = event && typeof event.payload === "object" ? event.payload : {};
53
+ return payload.ephemeral === true && normalizeString(payload.style) === RECAP_STYLE;
54
+ }
55
+
56
+ function parseFindingSeverity(text = "") {
57
+ const normalized = normalizeString(text);
58
+ if (!normalized) {
59
+ return "";
60
+ }
61
+ const findingMatch = /finding\s*:\s*\[(P[0-3])\]/i.exec(normalized);
62
+ if (findingMatch) {
63
+ return normalizeString(findingMatch[1]).toUpperCase();
64
+ }
65
+ const bracketMatch = /\[(P[0-3])\]/i.exec(normalized);
66
+ if (bracketMatch) {
67
+ return normalizeString(bracketMatch[1]).toUpperCase();
68
+ }
69
+ return "";
70
+ }
71
+
72
+ function buildFindingSummary(events = []) {
73
+ const summary = {
74
+ P0: 0,
75
+ P1: 0,
76
+ P2: 0,
77
+ P3: 0,
78
+ };
79
+ for (const event of events) {
80
+ const payload = event && typeof event.payload === "object" ? event.payload : {};
81
+ const severity =
82
+ parseFindingSeverity(payload.message) ||
83
+ normalizeString(payload.severity).toUpperCase() ||
84
+ parseFindingSeverity(payload.title);
85
+ if (Object.prototype.hasOwnProperty.call(summary, severity)) {
86
+ summary[severity] += 1;
87
+ }
88
+ }
89
+ return summary;
90
+ }
91
+
92
+ function countActiveLocks(events = []) {
93
+ const activeLocks = new Map();
94
+ for (const event of events) {
95
+ const eventName = normalizeString(event.event).toLowerCase();
96
+ const payload = event && typeof event.payload === "object" ? event.payload : {};
97
+ const filePath = normalizeString(payload.file || payload.filePath || payload.path).replace(/\\/g, "/");
98
+ if (!filePath) {
99
+ continue;
100
+ }
101
+ if (eventName === "file_lock") {
102
+ const holder = normalizeString(event.agent?.id || event.agentId);
103
+ activeLocks.set(filePath, holder || "unknown");
104
+ continue;
105
+ }
106
+ if (eventName === "file_unlock") {
107
+ activeLocks.delete(filePath);
108
+ }
109
+ }
110
+ return activeLocks.size;
111
+ }
112
+
113
+ function summarizeRecentActivity(events = [], { forAgentId = "", limit = 2 } = {}) {
114
+ const normalizedAgentId = normalizeString(forAgentId).toLowerCase();
115
+ const snippets = [];
116
+ for (let index = events.length - 1; index >= 0; index -= 1) {
117
+ const event = events[index];
118
+ const agentId = normalizeString(event.agent?.id || event.agentId);
119
+ if (!agentId || agentId.toLowerCase() === normalizedAgentId || agentId === SENTI_AGENT_ID) {
120
+ continue;
121
+ }
122
+ const payload = event && typeof event.payload === "object" ? event.payload : {};
123
+ const message = normalizeString(
124
+ payload.message || payload.response || payload.recap || payload.alert || payload.reason
125
+ );
126
+ if (!message) {
127
+ continue;
128
+ }
129
+ snippets.push(`${agentId}: ${message.replace(/\s+/g, " ").slice(0, 120)}`);
130
+ if (snippets.length >= Math.max(1, limit)) {
131
+ break;
132
+ }
133
+ }
134
+ return snippets.reverse();
135
+ }
136
+
137
+ async function readPendingTasks(sessionId, { forAgentId = "", targetPath = process.cwd() } = {}) {
138
+ const normalizedAgentId = normalizeString(forAgentId).toLowerCase();
139
+ if (!normalizedAgentId) {
140
+ return 0;
141
+ }
142
+ const paths = resolveSessionPaths(sessionId, { targetPath });
143
+ try {
144
+ const raw = await fsp.readFile(paths.tasksPath, "utf-8");
145
+ const parsed = JSON.parse(raw);
146
+ const tasks = Array.isArray(parsed?.tasks) ? parsed.tasks : [];
147
+ return tasks.filter((task) => {
148
+ const owner = normalizeString(task?.toAgentId).toLowerCase();
149
+ const status = normalizeString(task?.status).toUpperCase();
150
+ if (!owner || owner !== normalizedAgentId) {
151
+ return false;
152
+ }
153
+ return status === "PENDING" || status === "ACCEPTED";
154
+ }).length;
155
+ } catch (error) {
156
+ if (error && typeof error === "object" && error.code === "ENOENT") {
157
+ return 0;
158
+ }
159
+ return 0;
160
+ }
161
+ }
162
+
163
+ function buildElapsedMinutes(events = [], nowIso = new Date().toISOString()) {
164
+ if (!Array.isArray(events) || events.length === 0) {
165
+ return 0;
166
+ }
167
+ const firstEpoch = toEpoch(events[0]?.ts, nowIso);
168
+ const nowEpoch = toEpoch(nowIso, nowIso);
169
+ if (!Number.isFinite(firstEpoch) || !Number.isFinite(nowEpoch) || nowEpoch <= firstEpoch) {
170
+ return 0;
171
+ }
172
+ return Math.max(0, Math.floor((nowEpoch - firstEpoch) / 60_000));
173
+ }
174
+
175
+ function buildRecapKey(sessionId, targetPath) {
176
+ return `${path.resolve(String(targetPath || "."))}::${normalizeString(sessionId)}`;
177
+ }
178
+
179
+ function buildRecapText({
180
+ activeAgents = [],
181
+ totalFindings = 0,
182
+ activeLocks = 0,
183
+ pendingTasks = 0,
184
+ snippets = [],
185
+ } = {}) {
186
+ const agentText =
187
+ activeAgents.length > 0
188
+ ? `${activeAgents.length} active (${activeAgents.slice(0, 3).join(", ")})`
189
+ : "no active peers yet";
190
+ const findingText = `${totalFindings} finding${totalFindings === 1 ? "" : "s"} logged`;
191
+ const lockText = `${activeLocks} file lock${activeLocks === 1 ? "" : "s"} active`;
192
+ const pendingText =
193
+ pendingTasks > 0 ? `You have ${pendingTasks} pending task${pendingTasks === 1 ? "" : "s"}.` : "";
194
+ const snippetText = snippets.length > 0 ? `Recent: ${snippets.join(" | ")}` : "";
195
+ return `While you were away: ${agentText}. ${findingText}. ${lockText}. ${pendingText} ${snippetText}`.replace(
196
+ /\s+/g,
197
+ " "
198
+ ).trim();
199
+ }
200
+
201
+ function buildPeriodicText(recap = {}) {
202
+ const summary = recap.summary && typeof recap.summary === "object" ? recap.summary : {};
203
+ const elapsedMinutes = Number(summary.elapsedMinutes || 0);
204
+ const activeAgents = Number(summary.activeAgents || 0);
205
+ const totalFindings = Number(summary.totalFindingsCount || 0);
206
+ const activeLocks = Number(summary.activeLocks || 0);
207
+ const lastActor = normalizeString(summary.lastActorId);
208
+ const actorText = lastActor ? `${lastActor} active` : "no active actor";
209
+ return `Session active for ${elapsedMinutes}m. ${activeAgents} agents. ${totalFindings} findings. ${activeLocks} locks. ${actorText}.`;
210
+ }
211
+
212
+ export async function buildSessionRecap(
213
+ sessionId,
214
+ {
215
+ forAgentId = "",
216
+ maxEvents = DEFAULT_RECAP_MAX_EVENTS,
217
+ targetPath = process.cwd(),
218
+ nowIso = new Date().toISOString(),
219
+ } = {}
220
+ ) {
221
+ const normalizedSessionId = normalizeString(sessionId);
222
+ if (!normalizedSessionId) {
223
+ throw new Error("sessionId is required.");
224
+ }
225
+ const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
226
+ const normalizedTargetPath = path.resolve(String(targetPath || "."));
227
+ const normalizedMaxEvents = normalizePositiveInteger(maxEvents, DEFAULT_RECAP_MAX_EVENTS);
228
+ const normalizedForAgentId = normalizeString(forAgentId);
229
+
230
+ const events = await readStream(normalizedSessionId, {
231
+ targetPath: normalizedTargetPath,
232
+ tail: normalizedMaxEvents,
233
+ });
234
+ const visibleEvents = (Array.isArray(events) ? events : []).filter((event) => {
235
+ const agentId = normalizeString(event.agent?.id || event.agentId);
236
+ if (!agentId) {
237
+ return true;
238
+ }
239
+ if (agentId === SENTI_AGENT_ID && isRecapEvent(event)) {
240
+ return false;
241
+ }
242
+ return !normalizedForAgentId || agentId.toLowerCase() !== normalizedForAgentId.toLowerCase();
243
+ });
244
+
245
+ const activeAgentSet = new Set();
246
+ for (const event of visibleEvents) {
247
+ const agentId = normalizeString(event.agent?.id || event.agentId);
248
+ if (agentId && agentId !== SENTI_AGENT_ID) {
249
+ activeAgentSet.add(agentId);
250
+ }
251
+ }
252
+ const activeAgents = [...activeAgentSet].sort((left, right) => left.localeCompare(right));
253
+
254
+ const findingSummary = buildFindingSummary(visibleEvents);
255
+ const totalFindingsCount =
256
+ findingSummary.P0 + findingSummary.P1 + findingSummary.P2 + findingSummary.P3;
257
+ const activeLocks = countActiveLocks(visibleEvents);
258
+ const pendingTasks = await readPendingTasks(normalizedSessionId, {
259
+ forAgentId: normalizedForAgentId,
260
+ targetPath: normalizedTargetPath,
261
+ });
262
+ const snippets = summarizeRecentActivity(visibleEvents, {
263
+ forAgentId: normalizedForAgentId,
264
+ limit: 2,
265
+ });
266
+ const elapsedMinutes = buildElapsedMinutes(visibleEvents, normalizedNow);
267
+ const latestEvent = visibleEvents.length > 0 ? visibleEvents[visibleEvents.length - 1] : null;
268
+ const recapText = buildRecapText({
269
+ activeAgents,
270
+ totalFindings: totalFindingsCount,
271
+ activeLocks,
272
+ pendingTasks,
273
+ snippets,
274
+ });
275
+
276
+ return {
277
+ sessionId: normalizedSessionId,
278
+ forAgentId: normalizedForAgentId || null,
279
+ generatedAt: normalizedNow,
280
+ ephemeral: true,
281
+ style: RECAP_STYLE,
282
+ text: recapText,
283
+ recap: recapText,
284
+ summary: {
285
+ activeAgents: activeAgents.length,
286
+ activeAgentIds: activeAgents,
287
+ totalFindings: findingSummary,
288
+ totalFindingsCount,
289
+ activeLocks,
290
+ pendingTasksForAgent: pendingTasks,
291
+ snippets,
292
+ elapsedMinutes,
293
+ lastActorId: normalizeString(latestEvent?.agent?.id || latestEvent?.agentId) || null,
294
+ lastEventAt: latestEvent ? normalizeIsoTimestamp(latestEvent.ts, normalizedNow) : null,
295
+ },
296
+ };
297
+ }
298
+
299
+ export async function emitContextBriefing(
300
+ sessionId,
301
+ {
302
+ forAgentId = "",
303
+ maxEvents = DEFAULT_RECAP_MAX_EVENTS,
304
+ targetPath = process.cwd(),
305
+ nowIso = new Date().toISOString(),
306
+ } = {}
307
+ ) {
308
+ const recap = await buildSessionRecap(sessionId, {
309
+ forAgentId,
310
+ maxEvents,
311
+ targetPath,
312
+ nowIso,
313
+ });
314
+ const event = createAgentEvent({
315
+ event: "context_briefing",
316
+ agentId: SENTI_AGENT_ID,
317
+ agentModel: SENTI_MODEL,
318
+ sessionId,
319
+ ts: recap.generatedAt,
320
+ payload: {
321
+ forAgent: normalizeString(forAgentId) || null,
322
+ recap: recap.text,
323
+ ephemeral: true,
324
+ style: RECAP_STYLE,
325
+ generatedAt: recap.generatedAt,
326
+ },
327
+ });
328
+ const persisted = await appendToStream(sessionId, event, {
329
+ targetPath,
330
+ });
331
+ return {
332
+ recap,
333
+ event: persisted,
334
+ };
335
+ }
336
+
337
+ export async function shouldEmitRecap(
338
+ sessionId,
339
+ agentId,
340
+ {
341
+ lastReadAt = "",
342
+ targetPath = process.cwd(),
343
+ nowIso = new Date().toISOString(),
344
+ newEventThreshold = DEFAULT_RECAP_ACTIVITY_THRESHOLD,
345
+ inactivityMs = DEFAULT_RECAP_INTERVAL_MS,
346
+ } = {}
347
+ ) {
348
+ const normalizedSessionId = normalizeString(sessionId);
349
+ if (!normalizedSessionId) {
350
+ throw new Error("sessionId is required.");
351
+ }
352
+ const normalizedAgentId = normalizeString(agentId).toLowerCase();
353
+ const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
354
+ const normalizedInactivityMs = normalizePositiveInteger(inactivityMs, DEFAULT_RECAP_INTERVAL_MS);
355
+ const threshold = normalizePositiveInteger(newEventThreshold, DEFAULT_RECAP_ACTIVITY_THRESHOLD);
356
+
357
+ const eventsSinceRead = await readStream(normalizedSessionId, {
358
+ targetPath,
359
+ tail: 0,
360
+ since: normalizeString(lastReadAt) || null,
361
+ });
362
+ const relevantSinceRead = eventsSinceRead.filter((event) => {
363
+ if (isRecapEvent(event)) {
364
+ return false;
365
+ }
366
+ const sourceAgent = normalizeString(event.agent?.id || event.agentId).toLowerCase();
367
+ if (!sourceAgent || !normalizedAgentId) {
368
+ return true;
369
+ }
370
+ return sourceAgent !== normalizedAgentId;
371
+ });
372
+ if (relevantSinceRead.length > threshold) {
373
+ return true;
374
+ }
375
+
376
+ const latest = await readStream(normalizedSessionId, {
377
+ targetPath,
378
+ tail: 1,
379
+ });
380
+ const latestEvent = latest.length > 0 ? latest[latest.length - 1] : null;
381
+ if (!latestEvent || isRecapEvent(latestEvent)) {
382
+ return false;
383
+ }
384
+ const idleMs = Math.max(0, toEpoch(normalizedNow, normalizedNow) - toEpoch(latestEvent.ts, normalizedNow));
385
+ return idleMs >= normalizedInactivityMs;
386
+ }
387
+
388
+ export function emitPeriodicRecap(
389
+ sessionId,
390
+ {
391
+ intervalMs = DEFAULT_RECAP_INTERVAL_MS,
392
+ inactivityMs = DEFAULT_RECAP_INACTIVITY_MS,
393
+ maxEvents = DEFAULT_RECAP_MAX_EVENTS,
394
+ targetPath = process.cwd(),
395
+ nowProvider = () => new Date().toISOString(),
396
+ onEmit = null,
397
+ } = {}
398
+ ) {
399
+ const normalizedSessionId = normalizeString(sessionId);
400
+ if (!normalizedSessionId) {
401
+ throw new Error("sessionId is required.");
402
+ }
403
+ const normalizedTargetPath = path.resolve(String(targetPath || "."));
404
+ const normalizedIntervalMs = normalizePositiveInteger(intervalMs, DEFAULT_RECAP_INTERVAL_MS);
405
+ const normalizedInactivityMs = normalizePositiveInteger(
406
+ inactivityMs,
407
+ DEFAULT_RECAP_INACTIVITY_MS
408
+ );
409
+ const normalizedMaxEvents = normalizePositiveInteger(maxEvents, DEFAULT_RECAP_MAX_EVENTS);
410
+ const key = buildRecapKey(normalizedSessionId, normalizedTargetPath);
411
+ const existing = ACTIVE_RECAP_EMITTERS.get(key);
412
+ if (existing && existing.running) {
413
+ return existing.handle;
414
+ }
415
+
416
+ const state = {
417
+ key,
418
+ running: true,
419
+ startedAt: normalizeIsoTimestamp(nowProvider(), new Date().toISOString()),
420
+ intervalMs: normalizedIntervalMs,
421
+ inactivityMs: normalizedInactivityMs,
422
+ maxEvents: normalizedMaxEvents,
423
+ targetPath: normalizedTargetPath,
424
+ sessionId: normalizedSessionId,
425
+ timer: null,
426
+ lastRecapAt: null,
427
+ lastSourceEventAt: null,
428
+ lastRecapEvent: null,
429
+ stoppedReason: null,
430
+ };
431
+
432
+ const stop = (reason = "manual_stop") => {
433
+ if (!state.running) {
434
+ return {
435
+ stopped: false,
436
+ sessionId: state.sessionId,
437
+ reason: normalizeString(reason) || "manual_stop",
438
+ };
439
+ }
440
+ state.running = false;
441
+ state.stoppedReason = normalizeString(reason) || "manual_stop";
442
+ if (state.timer) {
443
+ clearInterval(state.timer);
444
+ state.timer = null;
445
+ }
446
+ ACTIVE_RECAP_EMITTERS.delete(key);
447
+ return {
448
+ stopped: true,
449
+ sessionId: state.sessionId,
450
+ reason: state.stoppedReason,
451
+ lastRecapAt: state.lastRecapAt,
452
+ };
453
+ };
454
+
455
+ const tickNow = async () => {
456
+ if (!state.running) {
457
+ return null;
458
+ }
459
+ const nowIso = normalizeIsoTimestamp(nowProvider(), new Date().toISOString());
460
+ const nowEpoch = toEpoch(nowIso, nowIso);
461
+ const events = await readStream(state.sessionId, {
462
+ targetPath: state.targetPath,
463
+ tail: state.maxEvents,
464
+ });
465
+ const nonRecapEvents = events.filter((event) => !isRecapEvent(event));
466
+ const latestSourceEvent = nonRecapEvents.length > 0 ? nonRecapEvents[nonRecapEvents.length - 1] : null;
467
+ if (!latestSourceEvent) {
468
+ return null;
469
+ }
470
+
471
+ const latestSourceEpoch = toEpoch(latestSourceEvent.ts, nowIso);
472
+ const idleMs = Math.max(0, nowEpoch - latestSourceEpoch);
473
+ if (idleMs >= state.inactivityMs) {
474
+ stop("inactive");
475
+ return null;
476
+ }
477
+
478
+ if (state.lastRecapAt) {
479
+ const sinceLastRecapMs = Math.max(0, nowEpoch - toEpoch(state.lastRecapAt, nowIso));
480
+ if (sinceLastRecapMs < state.intervalMs) {
481
+ return null;
482
+ }
483
+ }
484
+ if (state.lastSourceEventAt) {
485
+ const previousSourceEpoch = toEpoch(state.lastSourceEventAt, nowIso);
486
+ if (latestSourceEpoch <= previousSourceEpoch) {
487
+ return null;
488
+ }
489
+ }
490
+
491
+ const recap = await buildSessionRecap(state.sessionId, {
492
+ targetPath: state.targetPath,
493
+ maxEvents: state.maxEvents,
494
+ nowIso,
495
+ });
496
+ const text = buildPeriodicText(recap);
497
+ const event = createAgentEvent({
498
+ event: "session_recap",
499
+ agentId: SENTI_AGENT_ID,
500
+ agentModel: SENTI_MODEL,
501
+ sessionId: state.sessionId,
502
+ ts: nowIso,
503
+ payload: {
504
+ mode: "periodic",
505
+ recap: text,
506
+ ephemeral: true,
507
+ style: RECAP_STYLE,
508
+ generatedAt: nowIso,
509
+ },
510
+ });
511
+ const persisted = await appendToStream(state.sessionId, event, {
512
+ targetPath: state.targetPath,
513
+ });
514
+ state.lastRecapAt = nowIso;
515
+ state.lastSourceEventAt = normalizeIsoTimestamp(latestSourceEvent.ts, nowIso);
516
+ state.lastRecapEvent = persisted;
517
+
518
+ if (typeof onEmit === "function") {
519
+ await onEmit(persisted, recap);
520
+ }
521
+ return persisted;
522
+ };
523
+
524
+ const handle = {
525
+ sessionId: state.sessionId,
526
+ targetPath: state.targetPath,
527
+ isRunning: () => state.running,
528
+ stop,
529
+ tickNow,
530
+ getState: () => ({
531
+ sessionId: state.sessionId,
532
+ targetPath: state.targetPath,
533
+ startedAt: state.startedAt,
534
+ running: state.running,
535
+ intervalMs: state.intervalMs,
536
+ inactivityMs: state.inactivityMs,
537
+ lastRecapAt: state.lastRecapAt,
538
+ lastSourceEventAt: state.lastSourceEventAt,
539
+ stoppedReason: state.stoppedReason,
540
+ }),
541
+ };
542
+
543
+ state.handle = handle;
544
+ state.timer = setInterval(() => {
545
+ void tickNow().catch(() => {});
546
+ }, state.intervalMs);
547
+ if (typeof state.timer.unref === "function") {
548
+ state.timer.unref();
549
+ }
550
+
551
+ ACTIVE_RECAP_EMITTERS.set(key, state);
552
+ return handle;
553
+ }
554
+
555
+ export function getPeriodicRecapEmitter(sessionId, { targetPath = process.cwd() } = {}) {
556
+ const key = buildRecapKey(sessionId, targetPath);
557
+ const state = ACTIVE_RECAP_EMITTERS.get(key);
558
+ return state ? state.handle : null;
559
+ }
560
+
561
+ export {
562
+ ACTIVE_RECAP_EMITTERS,
563
+ DEFAULT_RECAP_INACTIVITY_MS,
564
+ DEFAULT_RECAP_INTERVAL_MS,
565
+ DEFAULT_RECAP_MAX_EVENTS,
566
+ RECAP_STYLE,
567
+ };
@@ -0,0 +1,82 @@
1
+ // Central payload-redaction utility for Senti session streams.
2
+ //
3
+ // Invariant: every event that lands on disk (stream.ndjson) or on the wire
4
+ // (API sync, human-message sync) passes through redactEventPayload before
5
+ // serialization. The sink call in stream.appendToStream is the enforcement
6
+ // point — if it writes raw, a secret leaks into the audit trail forever.
7
+ //
8
+ // Keep the SECRET_LIKE_PATTERN below in sync with src/session/sync.js.
9
+
10
+ const SECRET_LIKE_PATTERN =
11
+ /(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]+PRIVATE KEY-----|SENTINELAYER_TOKEN|AIDENID_API_KEY|NPM_TOKEN|xox[baprs]-[A-Za-z0-9-]+|AIza[0-9A-Za-z_-]{35}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,})/;
12
+
13
+ const REDACTION_MARKER = "[REDACTED]";
14
+ const MAX_STRING_LENGTH = 16_384;
15
+
16
+ function redactString(value) {
17
+ if (typeof value !== "string") {
18
+ return value;
19
+ }
20
+ const truncated =
21
+ value.length > MAX_STRING_LENGTH ? `${value.slice(0, MAX_STRING_LENGTH)}…[truncated]` : value;
22
+ if (!SECRET_LIKE_PATTERN.test(truncated)) {
23
+ return truncated;
24
+ }
25
+ return truncated.replace(new RegExp(SECRET_LIKE_PATTERN.source, "gi"), REDACTION_MARKER);
26
+ }
27
+
28
+ function redactValue(value, depth = 0) {
29
+ if (depth > 8) {
30
+ return REDACTION_MARKER;
31
+ }
32
+ if (typeof value === "string") {
33
+ return redactString(value);
34
+ }
35
+ if (Array.isArray(value)) {
36
+ return value.map((entry) => redactValue(entry, depth + 1));
37
+ }
38
+ if (value && typeof value === "object") {
39
+ const out = {};
40
+ for (const [key, inner] of Object.entries(value)) {
41
+ if (/^(authorization|cookie|set-cookie|x-api-key|api[_-]?key|secret|password|token)$/i.test(key)) {
42
+ out[key] = REDACTION_MARKER;
43
+ continue;
44
+ }
45
+ out[key] = redactValue(inner, depth + 1);
46
+ }
47
+ return out;
48
+ }
49
+ return value;
50
+ }
51
+
52
+ export function redactEventPayload(event) {
53
+ if (!event || typeof event !== "object" || Array.isArray(event)) {
54
+ return event;
55
+ }
56
+ const clone = { ...event };
57
+ if (clone.payload !== undefined) {
58
+ clone.payload = redactValue(clone.payload);
59
+ }
60
+ if (clone.message !== undefined) {
61
+ clone.message = redactValue(clone.message);
62
+ }
63
+ if (clone.body !== undefined) {
64
+ clone.body = redactValue(clone.body);
65
+ }
66
+ return clone;
67
+ }
68
+
69
+ export function containsSecret(value) {
70
+ if (typeof value === "string") {
71
+ return SECRET_LIKE_PATTERN.test(value);
72
+ }
73
+ if (Array.isArray(value)) {
74
+ return value.some((entry) => containsSecret(entry));
75
+ }
76
+ if (value && typeof value === "object") {
77
+ return Object.values(value).some((entry) => containsSecret(entry));
78
+ }
79
+ return false;
80
+ }
81
+
82
+ export const __secretPatternForTests = SECRET_LIKE_PATTERN;
@@ -232,11 +232,34 @@ function isDomainAllowed(candidateDomain, budgetEnvelope = {}) {
232
232
  if (!normalizedDomain) {
233
233
  return true;
234
234
  }
235
+ // SSRF hardening (Phase G, 2026-04-17): empty allowlist is default-DENY,
236
+ // not default-allow. A runtime caller that forgot to set the allowlist
237
+ // should hit a block, not silently let all egress through. Private/
238
+ // loopback/metadata ranges are ALWAYS blocked regardless of allowlist.
239
+ const PRIVATE_OR_METADATA = [
240
+ /^localhost$/,
241
+ /^127\./,
242
+ /^0\./,
243
+ /^10\./,
244
+ /^172\.(1[6-9]|2\d|3[01])\./,
245
+ /^192\.168\./,
246
+ /^169\.254\./, // link-local + cloud metadata
247
+ /^::1$/,
248
+ /^fe80:/i,
249
+ /^fc00:/i,
250
+ /^fd00:/i,
251
+ ];
252
+ if (PRIVATE_OR_METADATA.some((re) => re.test(normalizedDomain))) {
253
+ return false;
254
+ }
235
255
  if (
236
256
  !Array.isArray(budgetEnvelope.networkDomainAllowlist) ||
237
257
  budgetEnvelope.networkDomainAllowlist.length === 0
238
258
  ) {
239
- return true;
259
+ // Default-deny on empty allowlist. Callers MUST set a non-empty list
260
+ // to authorize egress. Opt-out via explicit `allowAllDomains: true`
261
+ // on the budget envelope (callers should be visible in code review).
262
+ return budgetEnvelope.allowAllDomains === true;
240
263
  }
241
264
  return budgetEnvelope.networkDomainAllowlist.some((allowedPattern) => {
242
265
  const normalizedPattern = normalizeString(allowedPattern).toLowerCase();