sentinelayer-cli 0.8.12 → 0.9.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.
- package/package.json +7 -2
- package/src/agents/backend/tools/timeout-audit.js +33 -17
- package/src/agents/devtestbot/config/definition.js +100 -0
- package/src/agents/devtestbot/config/system-prompt.js +92 -0
- package/src/agents/devtestbot/index.js +9 -0
- package/src/agents/devtestbot/runner.js +775 -0
- package/src/agents/devtestbot/tool.js +707 -0
- package/src/commands/legacy-args.js +4 -0
- package/src/commands/omargate.js +4 -0
- package/src/commands/session.js +960 -159
- package/src/commands/swarm.js +11 -2
- package/src/guide/generator.js +14 -0
- package/src/legacy-cli.js +35 -18
- package/src/prompt/generator.js +4 -16
- package/src/review/ai-review.js +95 -6
- package/src/review/dd-report-email-client.js +148 -0
- package/src/review/investor-dd-devtestbot.js +599 -0
- package/src/review/investor-dd-orchestrator.js +135 -3
- package/src/review/omargate-orchestrator.js +20 -2
- package/src/review/persona-prompts.js +34 -1
- package/src/review/report.js +61 -2
- package/src/scan/generator.js +1 -1
- package/src/session/coordination-guidance.js +49 -0
- package/src/session/daemon.js +3 -2
- package/src/session/event-identity.js +139 -0
- package/src/session/listener.js +330 -0
- package/src/session/live-source.js +11 -2
- package/src/session/mentions.js +130 -0
- package/src/session/remote-hydrate.js +223 -8
- package/src/session/setup-guides.js +3 -15
- package/src/session/store.js +117 -5
- package/src/session/stream.js +17 -7
- package/src/session/sync.js +375 -26
- package/src/session/title-sync.js +107 -0
- package/src/spec/generator.js +8 -10
- package/src/swarm/registry.js +20 -0
- package/src/swarm/runtime.js +139 -1
package/src/commands/swarm.js
CHANGED
|
@@ -517,6 +517,9 @@ export function registerSwarmCommand(program) {
|
|
|
517
517
|
.option("--scenario-file <path>", "Scenario DSL file (.sls) for runtime actions")
|
|
518
518
|
.option("--registry-file <path>", "Optional custom swarm registry file (when building plan inline)")
|
|
519
519
|
.option("--agents <ids>", "Comma-separated agent ids for inline plan mode", "security,testing,reliability")
|
|
520
|
+
.option("--agent <id>", "Single agent id alias for --agents")
|
|
521
|
+
.option("--scope <scope>", "Runtime scope alias for --scenario, used by devTestBot")
|
|
522
|
+
.option("--identity-id <id>", "AIdenID identity id for devTestBot runtime")
|
|
520
523
|
.option("--scenario <id>", "Scenario identifier for inline plan mode", "qa_audit")
|
|
521
524
|
.option(
|
|
522
525
|
"--objective <text>",
|
|
@@ -571,7 +574,7 @@ export function registerSwarmCommand(program) {
|
|
|
571
574
|
const registry = await loadSwarmRegistry({
|
|
572
575
|
registryFile: options.registryFile,
|
|
573
576
|
});
|
|
574
|
-
const selected = selectSwarmAgents(registry.agents, options.agents);
|
|
577
|
+
const selected = selectSwarmAgents(registry.agents, options.agent || options.agents);
|
|
575
578
|
if (selected.missing.length > 0) {
|
|
576
579
|
throw new Error(`Unknown agent id(s): ${selected.missing.join(", ")}`);
|
|
577
580
|
}
|
|
@@ -581,7 +584,7 @@ export function registerSwarmCommand(program) {
|
|
|
581
584
|
const selectedAgents = ensureOmarIncluded(registry.agents, selected.selected);
|
|
582
585
|
plan = buildSwarmExecutionPlan({
|
|
583
586
|
targetPath,
|
|
584
|
-
scenario: scenarioIdOverride || options.scenario,
|
|
587
|
+
scenario: scenarioIdOverride || options.scope || options.scenario,
|
|
585
588
|
objective: options.objective,
|
|
586
589
|
agents: selectedAgents,
|
|
587
590
|
maxParallel: parseMaxParallel(options.maxParallel),
|
|
@@ -612,6 +615,8 @@ export function registerSwarmCommand(program) {
|
|
|
612
615
|
execute: Boolean(options.execute),
|
|
613
616
|
maxSteps: parseMaxSteps(options.maxSteps),
|
|
614
617
|
startUrl: startUrlOverride || options.startUrl,
|
|
618
|
+
identityId: options.identityId,
|
|
619
|
+
devTestBotScope: options.scope || scenarioIdOverride || options.scenario,
|
|
615
620
|
playbookActions,
|
|
616
621
|
outputDir: options.outputDir,
|
|
617
622
|
env: process.env,
|
|
@@ -631,6 +636,10 @@ export function registerSwarmCommand(program) {
|
|
|
631
636
|
stop: runtime.stop,
|
|
632
637
|
usage: runtime.usage,
|
|
633
638
|
eventCount: runtime.eventCount,
|
|
639
|
+
findingCount: runtime.findingCount,
|
|
640
|
+
findings: runtime.findings,
|
|
641
|
+
artifactBundles: runtime.artifactBundles,
|
|
642
|
+
devTestBotRuns: runtime.devTestBotRuns,
|
|
634
643
|
runtimeDirectory: runtime.runtimeDirectory,
|
|
635
644
|
runtimeJsonPath: runtime.runtimeJsonPath,
|
|
636
645
|
runtimeMarkdownPath: runtime.runtimeMarkdownPath,
|
package/src/guide/generator.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCoordinationEtiquetteItems,
|
|
3
|
+
renderCoordinationMarkdownSection,
|
|
4
|
+
renderCoordinationTicketBlock,
|
|
5
|
+
} from "../session/coordination-guidance.js";
|
|
6
|
+
|
|
1
7
|
export const SUPPORTED_GUIDE_EXPORT_FORMATS = Object.freeze([
|
|
2
8
|
"jira",
|
|
3
9
|
"linear",
|
|
@@ -167,6 +173,8 @@ function buildTicket(phase, index) {
|
|
|
167
173
|
"",
|
|
168
174
|
"Acceptance criteria:",
|
|
169
175
|
acceptanceBlock || "1. Phase outcomes are verified by deterministic checks.",
|
|
176
|
+
"",
|
|
177
|
+
renderCoordinationTicketBlock(),
|
|
170
178
|
].join("\n"),
|
|
171
179
|
};
|
|
172
180
|
}
|
|
@@ -235,6 +243,8 @@ ${goal}
|
|
|
235
243
|
## Phase Execution Plan
|
|
236
244
|
${phaseMarkdown}
|
|
237
245
|
|
|
246
|
+
${renderCoordinationMarkdownSection()}
|
|
247
|
+
|
|
238
248
|
## Suggested PR Sequence
|
|
239
249
|
${resolvedPhases
|
|
240
250
|
.map((phase, index) => `${index + 1}. ${phase.title} (${phase.effort.label})`)
|
|
@@ -246,6 +256,7 @@ ${resolvedPhases
|
|
|
246
256
|
goal,
|
|
247
257
|
phases: resolvedPhases,
|
|
248
258
|
tickets,
|
|
259
|
+
coordinationRules: getCoordinationEtiquetteItems(),
|
|
249
260
|
markdown,
|
|
250
261
|
};
|
|
251
262
|
}
|
|
@@ -256,12 +267,14 @@ export function renderGuideExport({ format, guide }) {
|
|
|
256
267
|
project: guide.projectName,
|
|
257
268
|
generated_at: new Date().toISOString(),
|
|
258
269
|
issues: guide.tickets,
|
|
270
|
+
coordination_rules: Array.isArray(guide.coordinationRules) ? guide.coordinationRules : [],
|
|
259
271
|
};
|
|
260
272
|
|
|
261
273
|
if (normalized === "jira") {
|
|
262
274
|
return JSON.stringify(
|
|
263
275
|
{
|
|
264
276
|
format: "jira",
|
|
277
|
+
coordination_rules: payload.coordination_rules,
|
|
265
278
|
issues: payload.issues.map((issue) => ({
|
|
266
279
|
summary: issue.title,
|
|
267
280
|
description: issue.description,
|
|
@@ -279,6 +292,7 @@ export function renderGuideExport({ format, guide }) {
|
|
|
279
292
|
return JSON.stringify(
|
|
280
293
|
{
|
|
281
294
|
format: "linear",
|
|
295
|
+
coordination_rules: payload.coordination_rules,
|
|
282
296
|
issues: payload.issues.map((issue, index) => ({
|
|
283
297
|
title: issue.title,
|
|
284
298
|
description: issue.description,
|
package/src/legacy-cli.js
CHANGED
|
@@ -25,6 +25,10 @@ import { normalizeAgentEvent } from "./events/schema.js";
|
|
|
25
25
|
import { collectCodebaseIngest, formatIngestSummary } from "./ingest/engine.js";
|
|
26
26
|
import { getExpressTemplate, getPackageJsonTemplate, buildReadmeContent } from "./scaffold/templates.js";
|
|
27
27
|
import { generateScaffold } from "./scaffold/generator.js";
|
|
28
|
+
import {
|
|
29
|
+
getCoordinationEtiquetteItems,
|
|
30
|
+
renderCoordinationNumberedList,
|
|
31
|
+
} from "./session/coordination-guidance.js";
|
|
28
32
|
|
|
29
33
|
let DEFAULT_API_URL = process.env.SENTINELAYER_API_URL || "https://api.sentinelayer.com";
|
|
30
34
|
let DEFAULT_WEB_URL = process.env.SENTINELAYER_WEB_URL || "https://sentinelayer.com";
|
|
@@ -1121,6 +1125,10 @@ async function runLocalOmarGateCommand(args) {
|
|
|
1121
1125
|
const maxParallel =
|
|
1122
1126
|
parseInt(getCommandOptionValue(args, "--max-parallel") || "3", 10) || 3;
|
|
1123
1127
|
const streamEnabled = hasCommandOption(args, "--stream");
|
|
1128
|
+
const devTestBotEnabled = !hasCommandOption(args, "--no-devtestbot");
|
|
1129
|
+
const devTestBotBaseUrl = getCommandOptionValue(args, "--devtestbot-base-url") || "";
|
|
1130
|
+
const devTestBotScope = getCommandOptionValue(args, "--devtestbot-scope") || "";
|
|
1131
|
+
const emailOnComplete = getCommandOptionValue(args, "--email-on-complete") || "";
|
|
1124
1132
|
|
|
1125
1133
|
const targetPath = path.resolve(process.cwd(), pathArg);
|
|
1126
1134
|
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
|
|
@@ -1128,6 +1136,9 @@ async function runLocalOmarGateCommand(args) {
|
|
|
1128
1136
|
}
|
|
1129
1137
|
|
|
1130
1138
|
const { runInvestorDd } = await import("./review/investor-dd-orchestrator.js");
|
|
1139
|
+
const reportEmailClient = emailOnComplete
|
|
1140
|
+
? await import("./review/dd-report-email-client.js")
|
|
1141
|
+
: null;
|
|
1131
1142
|
if (!asJson) {
|
|
1132
1143
|
printSection("Investor-DD Audit");
|
|
1133
1144
|
printInfo(`Target: ${targetPath}`);
|
|
@@ -1141,6 +1152,24 @@ async function runLocalOmarGateCommand(args) {
|
|
|
1141
1152
|
outputDir: outputDirArg,
|
|
1142
1153
|
budgetOptions: { maxCostUsd, maxRuntimeMinutes, maxParallel },
|
|
1143
1154
|
dryRun,
|
|
1155
|
+
devTestBot: {
|
|
1156
|
+
enabled: devTestBotEnabled,
|
|
1157
|
+
baseUrl: devTestBotBaseUrl,
|
|
1158
|
+
scope: devTestBotScope,
|
|
1159
|
+
},
|
|
1160
|
+
reportEmail: emailOnComplete
|
|
1161
|
+
? {
|
|
1162
|
+
to: emailOnComplete,
|
|
1163
|
+
client: {
|
|
1164
|
+
send: ({ runId, to }) => reportEmailClient.sendDdReportEmail({
|
|
1165
|
+
runId,
|
|
1166
|
+
to,
|
|
1167
|
+
cwd: targetPath,
|
|
1168
|
+
env: process.env,
|
|
1169
|
+
}),
|
|
1170
|
+
},
|
|
1171
|
+
}
|
|
1172
|
+
: null,
|
|
1144
1173
|
onEvent: streamEnabled
|
|
1145
1174
|
? (event) => process.stdout.write(`${JSON.stringify(event)}\n`)
|
|
1146
1175
|
: () => {},
|
|
@@ -1950,6 +1979,7 @@ function buildCodingAgentConfigTemplate({ agentProfile, projectName }) {
|
|
|
1950
1979
|
"Read docs/spec.md, docs/build-guide.md, tasks/todo.md, and AGENT_HANDOFF_PROMPT.md in order.",
|
|
1951
1980
|
"Work one PR scope at a time and keep changes deterministic.",
|
|
1952
1981
|
"Run local checks before push: /omargate deep and /audit.",
|
|
1982
|
+
...getCoordinationEtiquetteItems(),
|
|
1953
1983
|
];
|
|
1954
1984
|
|
|
1955
1985
|
if (agentProfile.id === "aider") {
|
|
@@ -1960,7 +1990,7 @@ read:
|
|
|
1960
1990
|
- tasks/todo.md
|
|
1961
1991
|
- AGENT_HANDOFF_PROMPT.md
|
|
1962
1992
|
notes:
|
|
1963
|
-
- ${commonChecklist.join("\n - ")}
|
|
1993
|
+
- ${commonChecklist.map((item) => JSON.stringify(item)).join("\n - ")}
|
|
1964
1994
|
`;
|
|
1965
1995
|
}
|
|
1966
1996
|
|
|
@@ -2186,16 +2216,7 @@ Repo context:
|
|
|
2186
2216
|
|
|
2187
2217
|
## Multi-Agent Coordination (if session active)
|
|
2188
2218
|
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
1. **On start:** Run \`sl session join <id> --name <your-name> --role coder\`
|
|
2192
|
-
2. **Periodically:** Run \`sl session say <id> "status: working on <file>"\` so others know what you're doing
|
|
2193
|
-
3. **On unexpected file change:** Instead of stopping, run:
|
|
2194
|
-
\`sl session say <id> "help: unexpected change in <file> - who modified this?"\`
|
|
2195
|
-
Then check \`sl session read <id> --tail 5\` for the answer and continue.
|
|
2196
|
-
4. **On findings:** Run \`sl session say <id> "finding: [P2] <title> in <file>:<line>"\`
|
|
2197
|
-
5. **On completion:** Run \`sl session leave <id>\`
|
|
2198
|
-
6. **Before modifying a file:** Check \`sl session read <id> --tail 10\` for recent activity on that file
|
|
2219
|
+
${renderCoordinationNumberedList()}
|
|
2199
2220
|
|
|
2200
2221
|
Start now and continue autonomously.
|
|
2201
2222
|
`;
|
|
@@ -2204,12 +2225,8 @@ Start now and continue autonomously.
|
|
|
2204
2225
|
export function buildAgentsSessionGuideContent() {
|
|
2205
2226
|
return `# SentinelLayer Session Guide for AI Agents
|
|
2206
2227
|
|
|
2207
|
-
##
|
|
2208
|
-
|
|
2209
|
-
2. Join: \`sl session join <id> --name <your-short-name> --role <coder|reviewer|tester>\`
|
|
2210
|
-
3. Read context: \`sl session read <id> --tail 20\` — see what others are doing
|
|
2211
|
-
4. Work: emit status every 5 min, post findings, ask for help instead of stopping
|
|
2212
|
-
5. Leave: \`sl session leave <id>\` when done
|
|
2228
|
+
## Required Etiquette
|
|
2229
|
+
${renderCoordinationNumberedList()}
|
|
2213
2230
|
|
|
2214
2231
|
## Why This Matters
|
|
2215
2232
|
- Other agents can see what you're working on and avoid file conflicts
|
|
@@ -2299,7 +2316,7 @@ jobs:
|
|
|
2299
2316
|
fi
|
|
2300
2317
|
- name: Run Omar Gate
|
|
2301
2318
|
id: omar
|
|
2302
|
-
uses: mrrCarter/sentinelayer-v1-action@
|
|
2319
|
+
uses: mrrCarter/sentinelayer-v1-action@b13504565105b2496c5b1dbb7a3e9bf914c2a9f8
|
|
2303
2320
|
with:
|
|
2304
2321
|
sentinelayer_token: \${{ secrets.${normalizedSecret} }}${specIdBindingLine}
|
|
2305
2322
|
scan_mode: \${{ github.event_name == 'workflow_dispatch' && inputs.scan_mode || 'deep' }}
|
package/src/prompt/generator.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getCoordinationEtiquetteItems } from "../session/coordination-guidance.js";
|
|
2
|
+
|
|
1
3
|
export const SUPPORTED_PROMPT_TARGETS = Object.freeze([
|
|
2
4
|
"claude",
|
|
3
5
|
"cursor",
|
|
@@ -34,11 +36,7 @@ const TARGET_GUIDANCE = Object.freeze({
|
|
|
34
36
|
],
|
|
35
37
|
});
|
|
36
38
|
|
|
37
|
-
const SESSION_COORDINATION_GUIDANCE = Object.freeze(
|
|
38
|
-
"Multi-agent coordination: use `sl session` commands to communicate with other agents.",
|
|
39
|
-
"Always update the session chat room with your current activity so joining agents have context.",
|
|
40
|
-
"Never break your autonomous loop on unexpected file changes; ask in the session first.",
|
|
41
|
-
]);
|
|
39
|
+
const SESSION_COORDINATION_GUIDANCE = Object.freeze(getCoordinationEtiquetteItems());
|
|
42
40
|
|
|
43
41
|
function normalizeTarget(target) {
|
|
44
42
|
const normalized = String(target || "generic").trim().toLowerCase();
|
|
@@ -61,14 +59,6 @@ function buildAgentHeader(target) {
|
|
|
61
59
|
return headers[target] || headers.generic;
|
|
62
60
|
}
|
|
63
61
|
|
|
64
|
-
function shouldAppendSessionGuidance(specMarkdown) {
|
|
65
|
-
const normalized = String(specMarkdown || "").toLowerCase();
|
|
66
|
-
if (!normalized) {
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
return normalized.includes("coordination protocol") || normalized.includes("session");
|
|
70
|
-
}
|
|
71
|
-
|
|
72
62
|
export function resolvePromptTarget(target) {
|
|
73
63
|
return normalizeTarget(target);
|
|
74
64
|
}
|
|
@@ -96,9 +86,7 @@ export function generateExecutionPrompt({
|
|
|
96
86
|
}
|
|
97
87
|
|
|
98
88
|
const operatingRules = [...guidance];
|
|
99
|
-
|
|
100
|
-
operatingRules.push(...SESSION_COORDINATION_GUIDANCE);
|
|
101
|
-
}
|
|
89
|
+
operatingRules.push(...SESSION_COORDINATION_GUIDANCE);
|
|
102
90
|
const guidanceMarkdown = operatingRules.map((item, index) => `${index + 1}. ${item}`).join("\n");
|
|
103
91
|
|
|
104
92
|
const hasAidenId = specText.toLowerCase().includes("aidenid");
|
package/src/review/ai-review.js
CHANGED
|
@@ -58,6 +58,20 @@ function sanitizeExcerpt(text) {
|
|
|
58
58
|
.slice(0, 180);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
function cloneJsonCompatible(value) {
|
|
62
|
+
if (value === undefined || value === null || value === "") {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (typeof value === "string") {
|
|
66
|
+
return normalizeString(value) || null;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(JSON.stringify(value));
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
61
75
|
function extractJsonPayload(rawText) {
|
|
62
76
|
const text = String(rawText || "").trim();
|
|
63
77
|
if (!text) {
|
|
@@ -80,7 +94,10 @@ function extractJsonPayload(rawText) {
|
|
|
80
94
|
for (const candidate of candidates) {
|
|
81
95
|
try {
|
|
82
96
|
const parsed = JSON.parse(candidate);
|
|
83
|
-
if (
|
|
97
|
+
if (Array.isArray(parsed)) {
|
|
98
|
+
return { findings: parsed };
|
|
99
|
+
}
|
|
100
|
+
if (parsed && typeof parsed === "object") {
|
|
84
101
|
return parsed;
|
|
85
102
|
}
|
|
86
103
|
} catch {
|
|
@@ -118,14 +135,31 @@ function normalizeConfidence(value) {
|
|
|
118
135
|
return Math.max(0, Math.min(1, normalized));
|
|
119
136
|
}
|
|
120
137
|
|
|
138
|
+
function normalizeTrafficLight(value) {
|
|
139
|
+
const normalized = normalizeString(value).toLowerCase();
|
|
140
|
+
if (["green", "yellow", "red"].includes(normalized)) {
|
|
141
|
+
return normalized;
|
|
142
|
+
}
|
|
143
|
+
return "";
|
|
144
|
+
}
|
|
145
|
+
|
|
121
146
|
function normalizeAiFinding(rawFinding, index) {
|
|
122
147
|
if (!rawFinding || typeof rawFinding !== "object" || Array.isArray(rawFinding)) {
|
|
123
148
|
return null;
|
|
124
149
|
}
|
|
125
150
|
|
|
126
151
|
const message = normalizeString(rawFinding.title || rawFinding.message);
|
|
127
|
-
const
|
|
128
|
-
const
|
|
152
|
+
const evidence = normalizeString(rawFinding.evidence || rawFinding.excerpt);
|
|
153
|
+
const rootCause = normalizeString(rawFinding.rootCause || rawFinding.root_cause || rawFinding.rationale);
|
|
154
|
+
const recommendedFix = normalizeString(
|
|
155
|
+
rawFinding.recommendedFix || rawFinding.recommended_fix || rawFinding.suggestedFix
|
|
156
|
+
);
|
|
157
|
+
const rationale = normalizeString(rawFinding.rationale || rootCause || evidence || rawFinding.excerpt);
|
|
158
|
+
const suggestedFix = normalizeString(rawFinding.suggestedFix || recommendedFix);
|
|
159
|
+
const lensEvidence = cloneJsonCompatible(rawFinding.lensEvidence || rawFinding.lens_evidence);
|
|
160
|
+
const reproduction = cloneJsonCompatible(rawFinding.reproduction);
|
|
161
|
+
const userImpact = normalizeString(rawFinding.userImpact || rawFinding.user_impact);
|
|
162
|
+
const trafficLight = normalizeTrafficLight(rawFinding.trafficLight || rawFinding.traffic_light);
|
|
129
163
|
|
|
130
164
|
return {
|
|
131
165
|
severity: normalizeSeverity(rawFinding.severity),
|
|
@@ -134,6 +168,13 @@ function normalizeAiFinding(rawFinding, index) {
|
|
|
134
168
|
message: message || `AI finding ${index + 1}`,
|
|
135
169
|
rationale: rationale || "AI reviewer flagged a potential issue requiring validation.",
|
|
136
170
|
suggestedFix: suggestedFix || "Review and remediate this finding.",
|
|
171
|
+
evidence,
|
|
172
|
+
lensEvidence,
|
|
173
|
+
reproduction,
|
|
174
|
+
userImpact,
|
|
175
|
+
trafficLight,
|
|
176
|
+
rootCause,
|
|
177
|
+
recommendedFix: recommendedFix || suggestedFix || "Review and remediate this finding.",
|
|
137
178
|
confidence: normalizeConfidence(rawFinding.confidence),
|
|
138
179
|
};
|
|
139
180
|
}
|
|
@@ -212,6 +253,7 @@ export function buildAiReviewPrompt({
|
|
|
212
253
|
deterministicFindings = [],
|
|
213
254
|
scopedFiles = [],
|
|
214
255
|
specContext = null,
|
|
256
|
+
systemPrompt = "",
|
|
215
257
|
maxFindings = DEFAULT_AI_MAX_FINDINGS,
|
|
216
258
|
} = {}) {
|
|
217
259
|
const normalizedSummary = deterministicSummary || { P0: 0, P1: 0, P2: 0, P3: 0 };
|
|
@@ -226,7 +268,7 @@ export function buildAiReviewPrompt({
|
|
|
226
268
|
const specAcceptanceCriteriaCount = Number(specContext?.acceptanceCriteriaCount || 0);
|
|
227
269
|
const specPreview = Array.isArray(specContext?.endpointsPreview) ? specContext.endpointsPreview : [];
|
|
228
270
|
|
|
229
|
-
|
|
271
|
+
const basePrompt = [
|
|
230
272
|
"You are Sentinelayer Omar reviewer layer 9.3.",
|
|
231
273
|
"Review the deterministic findings and scoped files. Add ONLY materially new findings.",
|
|
232
274
|
"Do not repeat deterministic findings unless you add new exploitability rationale.",
|
|
@@ -241,6 +283,13 @@ export function buildAiReviewPrompt({
|
|
|
241
283
|
' "file": "relative/path",',
|
|
242
284
|
' "line": 1,',
|
|
243
285
|
' "title": "finding title",',
|
|
286
|
+
' "evidence": "concrete code excerpt or static trace evidence",',
|
|
287
|
+
' "lensEvidence": {"A": "passed|failed|not_applicable: short evidence"},',
|
|
288
|
+
' "reproduction": {"type": "static_trace|manual_step|shell|runtime_probe", "steps": ["step 1"]},',
|
|
289
|
+
' "user_impact": "operator/user/system impact",',
|
|
290
|
+
' "trafficLight": "green|yellow|red",',
|
|
291
|
+
' "rootCause": "why this exists",',
|
|
292
|
+
' "recommendedFix": "specific remediation",',
|
|
244
293
|
' "rationale": "why this matters",',
|
|
245
294
|
' "suggestedFix": "specific remediation",',
|
|
246
295
|
' "confidence": 0.0',
|
|
@@ -265,6 +314,18 @@ export function buildAiReviewPrompt({
|
|
|
265
314
|
"Deterministic findings:",
|
|
266
315
|
findingLines || "- none",
|
|
267
316
|
].join("\n");
|
|
317
|
+
|
|
318
|
+
const promptPrelude = normalizeString(systemPrompt);
|
|
319
|
+
if (!promptPrelude) {
|
|
320
|
+
return basePrompt;
|
|
321
|
+
}
|
|
322
|
+
return [
|
|
323
|
+
promptPrelude,
|
|
324
|
+
"",
|
|
325
|
+
"---",
|
|
326
|
+
"",
|
|
327
|
+
basePrompt,
|
|
328
|
+
].join("\n");
|
|
268
329
|
}
|
|
269
330
|
|
|
270
331
|
function maybeEstimateModelCost({ modelId, inputTokens, outputTokens }) {
|
|
@@ -305,6 +366,9 @@ function composeAiReviewMarkdown({
|
|
|
305
366
|
(finding, index) =>
|
|
306
367
|
`${index + 1}. [${finding.severity}] ${finding.file}:${finding.line} ${finding.message}\n` +
|
|
307
368
|
` rationale: ${finding.rationale}\n` +
|
|
369
|
+
(finding.evidence ? ` evidence: ${finding.evidence}\n` : "") +
|
|
370
|
+
(finding.userImpact ? ` user_impact: ${finding.userImpact}\n` : "") +
|
|
371
|
+
(finding.trafficLight ? ` traffic_light: ${finding.trafficLight}\n` : "") +
|
|
308
372
|
` suggested_fix: ${finding.suggestedFix}` +
|
|
309
373
|
(finding.confidence === null ? "" : `\n confidence: ${finding.confidence.toFixed(2)}`)
|
|
310
374
|
)
|
|
@@ -335,16 +399,25 @@ function composeAiReviewMarkdown({
|
|
|
335
399
|
}
|
|
336
400
|
|
|
337
401
|
function toReviewFinding(aiFinding, index) {
|
|
402
|
+
const suggestedFix = aiFinding.suggestedFix || aiFinding.recommendedFix;
|
|
338
403
|
return {
|
|
339
404
|
severity: aiFinding.severity,
|
|
340
405
|
file: aiFinding.file,
|
|
341
406
|
line: aiFinding.line,
|
|
342
407
|
message: aiFinding.message,
|
|
343
|
-
excerpt: sanitizeExcerpt(aiFinding.rationale),
|
|
408
|
+
excerpt: sanitizeExcerpt(aiFinding.evidence || aiFinding.rationale),
|
|
344
409
|
ruleId: `SL-AI-${String(index + 1).padStart(3, "0")}`,
|
|
345
|
-
suggestedFix
|
|
410
|
+
suggestedFix,
|
|
346
411
|
layer: "ai_reasoning",
|
|
347
412
|
confidence: aiFinding.confidence,
|
|
413
|
+
evidence: aiFinding.evidence,
|
|
414
|
+
lensEvidence: aiFinding.lensEvidence,
|
|
415
|
+
reproduction: aiFinding.reproduction,
|
|
416
|
+
userImpact: aiFinding.userImpact,
|
|
417
|
+
trafficLight: aiFinding.trafficLight,
|
|
418
|
+
rootCause: aiFinding.rootCause,
|
|
419
|
+
recommendedFix: aiFinding.recommendedFix || suggestedFix,
|
|
420
|
+
rationale: aiFinding.rationale,
|
|
348
421
|
};
|
|
349
422
|
}
|
|
350
423
|
|
|
@@ -357,6 +430,20 @@ function buildDryRunResponse({ deterministicSummary, maxFindings } = {}) {
|
|
|
357
430
|
file: "src/example.js",
|
|
358
431
|
line: 1 + index,
|
|
359
432
|
title: `DRY_RUN finding ${index + 1}`,
|
|
433
|
+
evidence: "const unsafe = exampleInput;",
|
|
434
|
+
lensEvidence: {
|
|
435
|
+
A: "not_applicable: no route/runtime boundary in dry-run fixture",
|
|
436
|
+
J: "failed: synthetic path needs targeted verification before merge",
|
|
437
|
+
K: "passed: no AI tool permission escalation in dry-run fixture",
|
|
438
|
+
},
|
|
439
|
+
reproduction: {
|
|
440
|
+
type: "static_trace",
|
|
441
|
+
steps: ["Inspect src/example.js", "Trace exampleInput into the synthetic finding path"],
|
|
442
|
+
},
|
|
443
|
+
user_impact: "Operator sees a synthetic risk used to validate OmarGate evidence plumbing.",
|
|
444
|
+
trafficLight: index === 0 ? "yellow" : "green",
|
|
445
|
+
rootCause: "DRY_RUN synthetic root cause for evidence-contract validation.",
|
|
446
|
+
recommendedFix: "Validate this path with targeted remediation.",
|
|
360
447
|
rationale: `Synthetic AI rationale with deterministic context P1=${deterministicSummary.P1}.`,
|
|
361
448
|
suggestedFix: "Validate this path with targeted remediation.",
|
|
362
449
|
confidence: index === 0 ? 0.72 : 0.54,
|
|
@@ -393,6 +480,7 @@ export async function runAiReviewLayer({
|
|
|
393
480
|
maxToolCalls = 0,
|
|
394
481
|
maxNoProgress = 3,
|
|
395
482
|
warningThresholdPercent = 80,
|
|
483
|
+
systemPrompt = "",
|
|
396
484
|
dryRun = false,
|
|
397
485
|
env = process.env,
|
|
398
486
|
} = {}) {
|
|
@@ -436,6 +524,7 @@ export async function runAiReviewLayer({
|
|
|
436
524
|
deterministicFindings: deterministic?.findings || [],
|
|
437
525
|
scopedFiles: deterministic?.scope?.scannedRelativeFiles || [],
|
|
438
526
|
specContext: deterministic?.layers?.specBinding || null,
|
|
527
|
+
systemPrompt,
|
|
439
528
|
maxFindings: normalizedMaxFindings,
|
|
440
529
|
});
|
|
441
530
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
4
|
+
import { requestJson } from "../auth/http.js";
|
|
5
|
+
|
|
6
|
+
export const DD_REPORT_EMAIL_TIMEOUT_MS = 10_000;
|
|
7
|
+
|
|
8
|
+
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
9
|
+
|
|
10
|
+
function normalizeString(value) {
|
|
11
|
+
return String(value || "").trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeReportEmail(value) {
|
|
15
|
+
const normalized = normalizeString(value);
|
|
16
|
+
if (!EMAIL_RE.test(normalized)) {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
return normalized;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildReportEmailIdempotencyKey({ runId, to }) {
|
|
23
|
+
const digest = crypto
|
|
24
|
+
.createHash("sha256")
|
|
25
|
+
.update(`${normalizeString(runId)}\0${normalizeString(to).toLowerCase()}`)
|
|
26
|
+
.digest("hex")
|
|
27
|
+
.slice(0, 32);
|
|
28
|
+
return `sl-cli-dd-email-${digest}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function redactDdEmailError(value) {
|
|
32
|
+
return normalizeString(value)
|
|
33
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
|
|
34
|
+
.replace(/[A-Za-z]:[\\/][^\s"'<>]+/g, "[LOCAL_PATH]")
|
|
35
|
+
.replace(/api[_-]?key\s*=\s*[^&\s]+/gi, "api_key=[REDACTED]")
|
|
36
|
+
.slice(0, 500);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeTimeoutMs(env = process.env) {
|
|
40
|
+
const parsed = Number(env.SENTINELAYER_DD_EMAIL_TIMEOUT_MS);
|
|
41
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
42
|
+
return Math.max(100, Math.floor(parsed));
|
|
43
|
+
}
|
|
44
|
+
return DD_REPORT_EMAIL_TIMEOUT_MS;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function errorResult({ runId, to, code, message, status = 0, requestId = null }) {
|
|
48
|
+
return {
|
|
49
|
+
queued: false,
|
|
50
|
+
sent: false,
|
|
51
|
+
runId: normalizeString(runId),
|
|
52
|
+
to: normalizeString(to),
|
|
53
|
+
code: normalizeString(code) || "DD_EMAIL_FAILED",
|
|
54
|
+
error: redactDdEmailError(message) || "DD report email request failed.",
|
|
55
|
+
status: Number(status || 0),
|
|
56
|
+
requestId: requestId ? String(requestId) : null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Trigger the API-side investor-DD report email endpoint for a completed run.
|
|
62
|
+
*
|
|
63
|
+
* The caller owns run completion and event emission. This helper only handles
|
|
64
|
+
* auth resolution, bounded network behavior, idempotency, and redacted errors.
|
|
65
|
+
*/
|
|
66
|
+
export async function sendDdReportEmail({
|
|
67
|
+
runId,
|
|
68
|
+
to,
|
|
69
|
+
cwd = process.cwd(),
|
|
70
|
+
env = process.env,
|
|
71
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
72
|
+
requestJsonImpl = requestJson,
|
|
73
|
+
timeoutMs = normalizeTimeoutMs(env),
|
|
74
|
+
} = {}) {
|
|
75
|
+
const normalizedRunId = normalizeString(runId);
|
|
76
|
+
const normalizedTo = normalizeReportEmail(to);
|
|
77
|
+
if (!normalizedRunId) {
|
|
78
|
+
return errorResult({ runId, to, code: "DD_EMAIL_RUN_ID_REQUIRED", message: "runId is required." });
|
|
79
|
+
}
|
|
80
|
+
if (!normalizedTo) {
|
|
81
|
+
return errorResult({ runId, to, code: "DD_EMAIL_INVALID_RECIPIENT", message: "Invalid report email recipient." });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let session = null;
|
|
85
|
+
try {
|
|
86
|
+
session = await resolveAuthSession({
|
|
87
|
+
cwd,
|
|
88
|
+
env,
|
|
89
|
+
autoRotate: false,
|
|
90
|
+
});
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return errorResult({
|
|
93
|
+
runId: normalizedRunId,
|
|
94
|
+
to: normalizedTo,
|
|
95
|
+
code: "DD_EMAIL_AUTH_UNAVAILABLE",
|
|
96
|
+
message: err instanceof Error ? err.message : String(err),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!session || !session.token) {
|
|
101
|
+
return errorResult({
|
|
102
|
+
runId: normalizedRunId,
|
|
103
|
+
to: normalizedTo,
|
|
104
|
+
code: "DD_EMAIL_AUTH_REQUIRED",
|
|
105
|
+
message: "Authenticate before sending DD report email.",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const apiUrl = normalizeString(session.apiUrl) || "https://api.sentinelayer.com";
|
|
110
|
+
const endpoint = `${apiUrl.replace(/\/+$/, "")}/api/v1/runs/${encodeURIComponent(
|
|
111
|
+
normalizedRunId,
|
|
112
|
+
)}/send-report-email`;
|
|
113
|
+
const idempotencyKey = buildReportEmailIdempotencyKey({
|
|
114
|
+
runId: normalizedRunId,
|
|
115
|
+
to: normalizedTo,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const response = await requestJsonImpl(endpoint, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: {
|
|
122
|
+
Authorization: `Bearer ${session.token}`,
|
|
123
|
+
},
|
|
124
|
+
idempotencyKey,
|
|
125
|
+
body: { to: normalizedTo },
|
|
126
|
+
timeoutMs,
|
|
127
|
+
maxRetries: 1,
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
queued: true,
|
|
131
|
+
sent: Boolean(response?.sent ?? true),
|
|
132
|
+
runId: normalizeString(response?.run_id) || normalizedRunId,
|
|
133
|
+
to: normalizeString(response?.to) || normalizedTo,
|
|
134
|
+
messageId: normalizeString(response?.message_id),
|
|
135
|
+
replay: Boolean(response?.replay),
|
|
136
|
+
idempotencyKey,
|
|
137
|
+
};
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return errorResult({
|
|
140
|
+
runId: normalizedRunId,
|
|
141
|
+
to: normalizedTo,
|
|
142
|
+
code: err?.code || "DD_EMAIL_REQUEST_FAILED",
|
|
143
|
+
message: err instanceof Error ? err.message : String(err),
|
|
144
|
+
status: err?.status || 0,
|
|
145
|
+
requestId: err?.requestId || null,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|