@ycodium-ai/plugin-pentest 0.0.0-stage → 0.2.2944
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/README.md +71 -2
- package/assets/icon.svg +8 -0
- package/assets/ui.js +178 -0
- package/dist/index.js +1164 -0
- package/locales/en.json +128 -0
- package/locales/zh-CN.json +128 -0
- package/package.json +115 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1164 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { definePlugin } from "@ycodium-ai/plugin-api";
|
|
3
|
+
|
|
4
|
+
// locales/en.json
|
|
5
|
+
var en_default = {
|
|
6
|
+
board: {
|
|
7
|
+
title: "Assessment record",
|
|
8
|
+
goal: "Goal",
|
|
9
|
+
target: "Target",
|
|
10
|
+
objective: "Objective",
|
|
11
|
+
authorization: "Authorization note",
|
|
12
|
+
authorizationHint: "An audit note. It does not grant permission.",
|
|
13
|
+
counts: "{{intents}} intents \xB7 {{facts}} evidence \xB7 {{findings}} findings \xB7 {{assets}} assets",
|
|
14
|
+
window: "The panel shows {{shown}} of {{total}} records. The report is the full store.",
|
|
15
|
+
loading: "Loading the record",
|
|
16
|
+
empty: "No record on this session yet",
|
|
17
|
+
open: "Open session",
|
|
18
|
+
related: "Delegated sessions",
|
|
19
|
+
relatedEmpty: "No delegated session has written to this record",
|
|
20
|
+
tabs: {
|
|
21
|
+
explore: "Explore",
|
|
22
|
+
findings: "Findings",
|
|
23
|
+
assets: "Assets",
|
|
24
|
+
report: "Report"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
explore: {
|
|
28
|
+
empty: "Select a record to see its detail",
|
|
29
|
+
edge: {
|
|
30
|
+
spawns: "spawns",
|
|
31
|
+
yields: "yields",
|
|
32
|
+
derived_from: "derived from",
|
|
33
|
+
proves: "proves",
|
|
34
|
+
parent: "parent"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
node: {
|
|
38
|
+
goal: "Goal",
|
|
39
|
+
intent: "Intent",
|
|
40
|
+
fact: "Evidence",
|
|
41
|
+
finding: "Finding",
|
|
42
|
+
asset: "Asset",
|
|
43
|
+
confidence: "Confidence {{value}}",
|
|
44
|
+
steps: "Observations",
|
|
45
|
+
close: "Close detail"
|
|
46
|
+
},
|
|
47
|
+
findings: {
|
|
48
|
+
empty: "No findings recorded",
|
|
49
|
+
all: "All severities",
|
|
50
|
+
affected: "Affected asset",
|
|
51
|
+
filter: "Severity"
|
|
52
|
+
},
|
|
53
|
+
severity: {
|
|
54
|
+
critical: "Critical",
|
|
55
|
+
high: "High",
|
|
56
|
+
medium: "Medium",
|
|
57
|
+
low: "Low",
|
|
58
|
+
info: "Info"
|
|
59
|
+
},
|
|
60
|
+
assets: {
|
|
61
|
+
empty: "No assets recorded",
|
|
62
|
+
list: "List",
|
|
63
|
+
tree: "Tree",
|
|
64
|
+
root: "Root",
|
|
65
|
+
filter: "Filter assets",
|
|
66
|
+
meta: "Notes"
|
|
67
|
+
},
|
|
68
|
+
assetType: {
|
|
69
|
+
"root-domain": "Root domain",
|
|
70
|
+
subdomain: "Subdomain",
|
|
71
|
+
ip: "IP",
|
|
72
|
+
service: "Service",
|
|
73
|
+
app: "App",
|
|
74
|
+
endpoint: "Endpoint"
|
|
75
|
+
},
|
|
76
|
+
factKind: {
|
|
77
|
+
port: "Port",
|
|
78
|
+
service: "Service",
|
|
79
|
+
vuln: "Issue",
|
|
80
|
+
finding: "Finding",
|
|
81
|
+
http: "HTTP",
|
|
82
|
+
info: "Info"
|
|
83
|
+
},
|
|
84
|
+
report: {
|
|
85
|
+
copy: "Copy Markdown",
|
|
86
|
+
download: "Download Markdown",
|
|
87
|
+
copied: "Report copied",
|
|
88
|
+
copyFailed: "Could not copy the report",
|
|
89
|
+
empty: "The report appears after a goal is recorded",
|
|
90
|
+
loading: "Preparing the report"
|
|
91
|
+
},
|
|
92
|
+
preset: {
|
|
93
|
+
description: "Record a goal, intents, evidence, assets, and findings on this session. The protocol is added to later turns."
|
|
94
|
+
},
|
|
95
|
+
hero: {
|
|
96
|
+
title: "Assessment record",
|
|
97
|
+
body: "Choose this preset before the first turn. The protocol is added to every later turn, and the session panel appears after the first goal.",
|
|
98
|
+
goal: "Goal",
|
|
99
|
+
evidence: "Evidence",
|
|
100
|
+
assets: "Assets",
|
|
101
|
+
findings: "Findings",
|
|
102
|
+
current: "This session records {{target}}"
|
|
103
|
+
},
|
|
104
|
+
settings: {
|
|
105
|
+
title: "Assessment record",
|
|
106
|
+
lead: "The panel shows a window of the latest records. Exports read the full store. A delegated session can only submit onto its parent.",
|
|
107
|
+
window: "Records kept in the session panel",
|
|
108
|
+
windowHint: "From 20 to 200. The stored record is not trimmed.",
|
|
109
|
+
authorization: "Include the authorization note in exported reports",
|
|
110
|
+
saved: "Saved",
|
|
111
|
+
card: {
|
|
112
|
+
title: "Assessment record",
|
|
113
|
+
description: "Window size and report options are in the Assessment record section."
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
card: {
|
|
117
|
+
recorded: "Recorded {{id}}",
|
|
118
|
+
summary: "Recorded",
|
|
119
|
+
report: "Report ready"
|
|
120
|
+
},
|
|
121
|
+
tool: {
|
|
122
|
+
add_goal: "Add goal",
|
|
123
|
+
add_intent: "Add intent",
|
|
124
|
+
add_fact: "Add evidence",
|
|
125
|
+
add_finding: "Add finding",
|
|
126
|
+
add_asset: "Add asset",
|
|
127
|
+
state: "Record state",
|
|
128
|
+
graph: "Record graph",
|
|
129
|
+
report: "Assessment report",
|
|
130
|
+
submit: "Submit record"
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// src/names.ts
|
|
135
|
+
var PLUGIN_ID = "pentest";
|
|
136
|
+
var GRAPH_PROJECTION = "graph";
|
|
137
|
+
var MOUNTED_PROJECTION = "mounted";
|
|
138
|
+
var PRESET_ID = "pentest";
|
|
139
|
+
var TOOL_SHORT_NAMES = [
|
|
140
|
+
"add_goal",
|
|
141
|
+
"add_intent",
|
|
142
|
+
"add_fact",
|
|
143
|
+
"add_finding",
|
|
144
|
+
"add_asset",
|
|
145
|
+
"state",
|
|
146
|
+
"graph",
|
|
147
|
+
"report",
|
|
148
|
+
"submit"
|
|
149
|
+
];
|
|
150
|
+
var SUBMIT_TOOL = "submit";
|
|
151
|
+
var wireName = (name) => `${PLUGIN_ID}_${name}`;
|
|
152
|
+
var DECISION_WIRE_NAMES = new Set(
|
|
153
|
+
TOOL_SHORT_NAMES.filter((name) => name !== SUBMIT_TOOL).map((name) => wireName(name))
|
|
154
|
+
);
|
|
155
|
+
var SUBMIT_WIRE_NAME = wireName(SUBMIT_TOOL);
|
|
156
|
+
|
|
157
|
+
// src/guard.ts
|
|
158
|
+
async function decideRecordGuard(name, threadId, parentOf2) {
|
|
159
|
+
if (!DECISION_WIRE_NAMES.has(name) && name !== SUBMIT_WIRE_NAME) return { decision: "allow" };
|
|
160
|
+
let parent = null;
|
|
161
|
+
if (threadId !== null && threadId.length > 0) {
|
|
162
|
+
try {
|
|
163
|
+
parent = await parentOf2(threadId);
|
|
164
|
+
} catch {
|
|
165
|
+
return {
|
|
166
|
+
decision: "deny",
|
|
167
|
+
reason: "This session could not be read, so the record tools are refused."
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const delegated = parent !== null && parent.length > 0;
|
|
172
|
+
if (delegated && name !== SUBMIT_WIRE_NAME) {
|
|
173
|
+
return {
|
|
174
|
+
decision: "deny",
|
|
175
|
+
reason: "A delegated session can only record through pentest_submit."
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (!delegated && name === SUBMIT_WIRE_NAME) {
|
|
179
|
+
return { decision: "deny", reason: "pentest_submit only runs in a delegated session." };
|
|
180
|
+
}
|
|
181
|
+
return { decision: "allow" };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/limits.ts
|
|
185
|
+
var WINDOW_DEFAULT = 200;
|
|
186
|
+
var WINDOW_MIN = 20;
|
|
187
|
+
var WINDOW_MAX = 200;
|
|
188
|
+
var TEXT_CAP = 4e3;
|
|
189
|
+
var STEP_CAP = 1e3;
|
|
190
|
+
var STEP_MAX = 20;
|
|
191
|
+
var REPORT_TEXT_CAP = 24e3;
|
|
192
|
+
|
|
193
|
+
// src/model.ts
|
|
194
|
+
var FACT_KINDS = ["port", "service", "vuln", "finding", "http", "info"];
|
|
195
|
+
var SEVERITIES = ["critical", "high", "medium", "low", "info"];
|
|
196
|
+
var ASSET_TYPES = ["root-domain", "subdomain", "ip", "service", "app", "endpoint"];
|
|
197
|
+
var emptyCounters = () => ({ intent: 0, fact: 0, finding: 0, asset: 0, edge: 0 });
|
|
198
|
+
var emptyEngagement = () => ({
|
|
199
|
+
goal: null,
|
|
200
|
+
nodes: [],
|
|
201
|
+
assets: [],
|
|
202
|
+
edges: [],
|
|
203
|
+
counters: emptyCounters()
|
|
204
|
+
});
|
|
205
|
+
var countsOf = (engagement) => ({
|
|
206
|
+
intents: engagement.nodes.filter((node) => node.kind === "intent").length,
|
|
207
|
+
facts: engagement.nodes.filter((node) => node.kind === "fact").length,
|
|
208
|
+
findings: engagement.nodes.filter((node) => node.kind === "finding").length,
|
|
209
|
+
assets: engagement.assets.length
|
|
210
|
+
});
|
|
211
|
+
var fail = (error) => ({ ok: false, error });
|
|
212
|
+
var text = (value, cap) => {
|
|
213
|
+
if (typeof value !== "string") return null;
|
|
214
|
+
const trimmed = value.trim();
|
|
215
|
+
if (trimmed.length === 0 || trimmed.length > cap) return null;
|
|
216
|
+
return trimmed;
|
|
217
|
+
};
|
|
218
|
+
var optionalText = (value, cap) => {
|
|
219
|
+
if (value === void 0 || value === null || value === "") return "";
|
|
220
|
+
return text(value, cap);
|
|
221
|
+
};
|
|
222
|
+
var oneOf = (value, allowed) => typeof value === "string" && allowed.includes(value) ? value : null;
|
|
223
|
+
var bump = (counters, key) => {
|
|
224
|
+
const n = counters[key] + 1;
|
|
225
|
+
return { counters: { ...counters, [key]: n }, n };
|
|
226
|
+
};
|
|
227
|
+
function addGoal(_current, input) {
|
|
228
|
+
const target = text(input.target, 500);
|
|
229
|
+
const objective = text(input.objective, TEXT_CAP);
|
|
230
|
+
const authorization = optionalText(input.authorization, TEXT_CAP);
|
|
231
|
+
if (target === null) return fail("target is required");
|
|
232
|
+
if (objective === null) return fail("objective is required");
|
|
233
|
+
if (authorization === null) return fail("authorization is too long");
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
id: "goal-1",
|
|
237
|
+
engagement: {
|
|
238
|
+
goal: { id: "goal-1", target, objective, authorization },
|
|
239
|
+
nodes: [],
|
|
240
|
+
assets: [],
|
|
241
|
+
edges: [],
|
|
242
|
+
counters: emptyCounters()
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function addIntent(current, input) {
|
|
247
|
+
if (current.goal === null) return fail("record a goal first");
|
|
248
|
+
const anchorKind = oneOf(input.anchorKind, ["goal", "fact"]);
|
|
249
|
+
const anchorId = text(input.anchorId, 80);
|
|
250
|
+
const title = text(input.title, 200);
|
|
251
|
+
const detail = optionalText(input.detail, TEXT_CAP);
|
|
252
|
+
if (anchorKind === null || anchorId === null) return fail("intent needs exactly one anchor");
|
|
253
|
+
if (title === null) return fail("title is required");
|
|
254
|
+
if (detail === null) return fail("detail is too long");
|
|
255
|
+
if (anchorKind === "goal" && anchorId !== current.goal.id)
|
|
256
|
+
return fail("goal anchor was not found");
|
|
257
|
+
if (anchorKind === "fact" && !current.nodes.some((node2) => node2.kind === "fact" && node2.id === anchorId)) {
|
|
258
|
+
return fail("fact anchor was not found");
|
|
259
|
+
}
|
|
260
|
+
const intent = bump(current.counters, "intent");
|
|
261
|
+
const edge = bump(intent.counters, "edge");
|
|
262
|
+
const id = `intent-${String(intent.n)}`;
|
|
263
|
+
const node = { id, kind: "intent", title, detail };
|
|
264
|
+
const link = {
|
|
265
|
+
id: `edge-${String(edge.n)}`,
|
|
266
|
+
kind: anchorKind === "goal" ? "spawns" : "derived_from",
|
|
267
|
+
sourceId: anchorId,
|
|
268
|
+
targetId: id
|
|
269
|
+
};
|
|
270
|
+
return {
|
|
271
|
+
ok: true,
|
|
272
|
+
id,
|
|
273
|
+
engagement: {
|
|
274
|
+
...current,
|
|
275
|
+
counters: edge.counters,
|
|
276
|
+
nodes: [...current.nodes, node],
|
|
277
|
+
edges: [...current.edges, link]
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function addFact(current, input) {
|
|
282
|
+
const intentId = text(input.intentId, 80);
|
|
283
|
+
const factKind = oneOf(input.factKind, FACT_KINDS);
|
|
284
|
+
const detail = text(input.detail, TEXT_CAP);
|
|
285
|
+
const target = optionalText(input.target, 500);
|
|
286
|
+
if (intentId === null || !current.nodes.some((node2) => node2.kind === "intent" && node2.id === intentId)) {
|
|
287
|
+
return fail("intent was not found");
|
|
288
|
+
}
|
|
289
|
+
if (factKind === null) return fail("fact kind is not recognized");
|
|
290
|
+
if (detail === null) return fail("detail is required");
|
|
291
|
+
if (target === null) return fail("target is too long");
|
|
292
|
+
const confidence = readConfidence(input.confidence);
|
|
293
|
+
if (confidence === null) return fail("confidence must be from 0 to 1");
|
|
294
|
+
const fact = bump(current.counters, "fact");
|
|
295
|
+
const edge = bump(fact.counters, "edge");
|
|
296
|
+
const id = `fact-${String(fact.n)}`;
|
|
297
|
+
const node = { id, kind: "fact", factKind, intentId, target, detail, confidence };
|
|
298
|
+
const link = {
|
|
299
|
+
id: `edge-${String(edge.n)}`,
|
|
300
|
+
kind: "yields",
|
|
301
|
+
sourceId: intentId,
|
|
302
|
+
targetId: id
|
|
303
|
+
};
|
|
304
|
+
return {
|
|
305
|
+
ok: true,
|
|
306
|
+
id,
|
|
307
|
+
engagement: {
|
|
308
|
+
...current,
|
|
309
|
+
counters: edge.counters,
|
|
310
|
+
nodes: [...current.nodes, node],
|
|
311
|
+
edges: [...current.edges, link]
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function addFinding(current, input) {
|
|
316
|
+
const intentId = text(input.intentId, 80);
|
|
317
|
+
const title = text(input.title, 200);
|
|
318
|
+
const severity = oneOf(input.severity, SEVERITIES);
|
|
319
|
+
const description = optionalText(input.description, TEXT_CAP);
|
|
320
|
+
if (intentId === null || !current.nodes.some((node2) => node2.kind === "intent" && node2.id === intentId)) {
|
|
321
|
+
return fail("intent was not found");
|
|
322
|
+
}
|
|
323
|
+
if (title === null) return fail("title is required");
|
|
324
|
+
if (severity === null) return fail("severity is not recognized");
|
|
325
|
+
if (description === null) return fail("description is too long");
|
|
326
|
+
const steps = readSteps(input.steps);
|
|
327
|
+
if (steps === null) return fail("at least one observation step is required");
|
|
328
|
+
const affected = optionalAsset(current, input.affectedAssetId);
|
|
329
|
+
if (affected === null) return fail("affected asset was not found");
|
|
330
|
+
const finding = bump(current.counters, "finding");
|
|
331
|
+
const edge = bump(finding.counters, "edge");
|
|
332
|
+
const id = `finding-${String(finding.n)}`;
|
|
333
|
+
const node = {
|
|
334
|
+
id,
|
|
335
|
+
kind: "finding",
|
|
336
|
+
intentId,
|
|
337
|
+
title,
|
|
338
|
+
severity,
|
|
339
|
+
description,
|
|
340
|
+
steps,
|
|
341
|
+
...affected.length > 0 ? { affectedAssetId: affected } : {}
|
|
342
|
+
};
|
|
343
|
+
const link = {
|
|
344
|
+
id: `edge-${String(edge.n)}`,
|
|
345
|
+
kind: "proves",
|
|
346
|
+
sourceId: intentId,
|
|
347
|
+
targetId: id
|
|
348
|
+
};
|
|
349
|
+
return {
|
|
350
|
+
ok: true,
|
|
351
|
+
id,
|
|
352
|
+
engagement: {
|
|
353
|
+
...current,
|
|
354
|
+
counters: edge.counters,
|
|
355
|
+
nodes: [...current.nodes, node],
|
|
356
|
+
edges: [...current.edges, link]
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function addAsset(current, input) {
|
|
361
|
+
if (current.goal === null) return fail("record a goal first");
|
|
362
|
+
const type = oneOf(input.type, ASSET_TYPES);
|
|
363
|
+
const value = text(input.value, 500);
|
|
364
|
+
const meta = optionalText(input.meta, TEXT_CAP);
|
|
365
|
+
if (type === null) return fail("asset type is not recognized");
|
|
366
|
+
if (value === null) return fail("value is required");
|
|
367
|
+
if (meta === null) return fail("meta is too long");
|
|
368
|
+
const parent = optionalAsset(current, input.parentId);
|
|
369
|
+
if (parent === null) return fail("parent asset was not found");
|
|
370
|
+
const asset = bump(current.counters, "asset");
|
|
371
|
+
const id = `asset-${String(asset.n)}`;
|
|
372
|
+
let counters = asset.counters;
|
|
373
|
+
let edges = current.edges;
|
|
374
|
+
if (parent.length > 0) {
|
|
375
|
+
const edge = bump(counters, "edge");
|
|
376
|
+
counters = edge.counters;
|
|
377
|
+
edges = [
|
|
378
|
+
...edges,
|
|
379
|
+
{ id: `edge-${String(edge.n)}`, kind: "parent", sourceId: parent, targetId: id }
|
|
380
|
+
];
|
|
381
|
+
}
|
|
382
|
+
const node = { id, type, value, meta };
|
|
383
|
+
return {
|
|
384
|
+
ok: true,
|
|
385
|
+
id,
|
|
386
|
+
engagement: { ...current, counters, assets: [...current.assets, node], edges }
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function applySubmit(current, input) {
|
|
390
|
+
if (input.recordKind === "fact") {
|
|
391
|
+
return addFact(current, input);
|
|
392
|
+
}
|
|
393
|
+
if (input.recordKind === "asset") return addAsset(current, input);
|
|
394
|
+
return addFinding(current, input);
|
|
395
|
+
}
|
|
396
|
+
function windowOf(engagement, limit) {
|
|
397
|
+
const cap = clampWindow(limit);
|
|
398
|
+
const nodes = engagement.nodes.slice(-cap);
|
|
399
|
+
const assets = engagement.assets.slice(-cap);
|
|
400
|
+
const keep = /* @__PURE__ */ new Set([
|
|
401
|
+
...engagement.goal === null ? [] : [engagement.goal.id],
|
|
402
|
+
...nodes.map((node) => node.id),
|
|
403
|
+
...assets.map((asset) => asset.id)
|
|
404
|
+
]);
|
|
405
|
+
const edges = engagement.edges.filter((edge) => keep.has(edge.sourceId) && keep.has(edge.targetId)).slice(-cap);
|
|
406
|
+
const totals = countsOf(engagement);
|
|
407
|
+
const shown = countsOf({ ...engagement, nodes, assets });
|
|
408
|
+
return {
|
|
409
|
+
goal: engagement.goal,
|
|
410
|
+
nodes,
|
|
411
|
+
assets,
|
|
412
|
+
edges,
|
|
413
|
+
counts: shown,
|
|
414
|
+
totals,
|
|
415
|
+
truncated: nodes.length !== engagement.nodes.length || assets.length !== engagement.assets.length || edges.length !== engagement.edges.filter((edge) => keep.has(edge.sourceId) && keep.has(edge.targetId)).length
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function reportMarkdown(engagement, includeAuthorization) {
|
|
419
|
+
if (engagement.goal === null) return "No goal has been recorded.\n";
|
|
420
|
+
const lines = [
|
|
421
|
+
"# Assessment record",
|
|
422
|
+
"",
|
|
423
|
+
"## Goal",
|
|
424
|
+
"",
|
|
425
|
+
`- Target: ${engagement.goal.target}`,
|
|
426
|
+
`- Objective: ${engagement.goal.objective}`
|
|
427
|
+
];
|
|
428
|
+
if (includeAuthorization) {
|
|
429
|
+
lines.push(`- Authorization note: ${engagement.goal.authorization || "(none)"}`);
|
|
430
|
+
lines.push("- The authorization note is an audit field. It does not grant permission.");
|
|
431
|
+
}
|
|
432
|
+
const findings = engagement.nodes.filter((node) => node.kind === "finding");
|
|
433
|
+
const findingBody = [];
|
|
434
|
+
for (const finding of findings) {
|
|
435
|
+
if (findingBody.length > 0) findingBody.push("");
|
|
436
|
+
const asset = finding.affectedAssetId === void 0 ? "" : ` (${finding.affectedAssetId})`;
|
|
437
|
+
findingBody.push(
|
|
438
|
+
`### ${finding.severity}: ${finding.title}`,
|
|
439
|
+
"",
|
|
440
|
+
`Intent: ${finding.intentId}${asset}`,
|
|
441
|
+
""
|
|
442
|
+
);
|
|
443
|
+
if (finding.description.length > 0) findingBody.push(finding.description, "");
|
|
444
|
+
findingBody.push("Observations:");
|
|
445
|
+
finding.steps.forEach((step, index) => findingBody.push(`${String(index + 1)}. ${step}`));
|
|
446
|
+
}
|
|
447
|
+
appendReportSection(lines, "Findings", findingBody);
|
|
448
|
+
const assetBody = engagement.assets.map((asset) => {
|
|
449
|
+
const parent = engagement.edges.find(
|
|
450
|
+
(edge) => edge.kind === "parent" && edge.targetId === asset.id
|
|
451
|
+
);
|
|
452
|
+
return `- ${asset.id} ${asset.type} ${asset.value} (parent: ${parent?.sourceId ?? "none"})`;
|
|
453
|
+
});
|
|
454
|
+
appendReportSection(lines, "Assets", assetBody);
|
|
455
|
+
const facts = engagement.nodes.filter((node) => node.kind === "fact");
|
|
456
|
+
appendReportSection(
|
|
457
|
+
lines,
|
|
458
|
+
"Evidence",
|
|
459
|
+
facts.map(
|
|
460
|
+
(fact) => `- ${fact.id} ${fact.factKind} on ${fact.intentId} (${String(fact.confidence)}): ${fact.detail}`
|
|
461
|
+
)
|
|
462
|
+
);
|
|
463
|
+
const intents = engagement.nodes.filter((node) => node.kind === "intent");
|
|
464
|
+
appendReportSection(
|
|
465
|
+
lines,
|
|
466
|
+
"Intents",
|
|
467
|
+
intents.map((intent) => `- ${intent.id} ${intent.title}`)
|
|
468
|
+
);
|
|
469
|
+
lines.push("");
|
|
470
|
+
const markdown = lines.join("\n");
|
|
471
|
+
if (markdown.length <= REPORT_TEXT_CAP) return markdown;
|
|
472
|
+
return `${markdown.slice(0, REPORT_TEXT_CAP)}
|
|
473
|
+
|
|
474
|
+
[truncated]
|
|
475
|
+
`;
|
|
476
|
+
}
|
|
477
|
+
function appendReportSection(lines, title, body) {
|
|
478
|
+
lines.push("", `## ${title}`, "");
|
|
479
|
+
if (body.length === 0) {
|
|
480
|
+
lines.push("None recorded.");
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
lines.push(...body);
|
|
484
|
+
}
|
|
485
|
+
function clampWindow(value) {
|
|
486
|
+
if (!Number.isFinite(value)) return WINDOW_MAX;
|
|
487
|
+
return Math.min(WINDOW_MAX, Math.max(WINDOW_MIN, Math.floor(value)));
|
|
488
|
+
}
|
|
489
|
+
function isEngagement(value) {
|
|
490
|
+
if (typeof value !== "object" || value === null) return false;
|
|
491
|
+
const record = value;
|
|
492
|
+
return record.goal !== void 0 && Array.isArray(record.nodes) && Array.isArray(record.assets) && Array.isArray(record.edges) && typeof record.counters === "object" && record.counters !== null;
|
|
493
|
+
}
|
|
494
|
+
function readConfidence(value) {
|
|
495
|
+
if (value === void 0 || value === null || value === "") return 0.5;
|
|
496
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) return null;
|
|
497
|
+
return value;
|
|
498
|
+
}
|
|
499
|
+
function readSteps(value) {
|
|
500
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > STEP_MAX) return null;
|
|
501
|
+
const steps = [];
|
|
502
|
+
for (const step of value) {
|
|
503
|
+
const line = text(step, STEP_CAP);
|
|
504
|
+
if (line === null) return null;
|
|
505
|
+
steps.push(line);
|
|
506
|
+
}
|
|
507
|
+
return steps;
|
|
508
|
+
}
|
|
509
|
+
function optionalAsset(current, value) {
|
|
510
|
+
if (value === void 0 || value === null || value === "") return "";
|
|
511
|
+
const id = text(value, 80);
|
|
512
|
+
if (id === null) return null;
|
|
513
|
+
return current.assets.some((asset) => asset.id === id) ? id : null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// src/protocol.ts
|
|
517
|
+
var PROTOCOL_TEXT = [
|
|
518
|
+
"Assessment record protocol.",
|
|
519
|
+
"",
|
|
520
|
+
"Record an authorized assessment as a graph on this session. The authorization field is an audit note: who granted permission, or a written reference. It is not a permission check. Host approval still applies to every other tool.",
|
|
521
|
+
"",
|
|
522
|
+
`Start with ${wireName("add_goal")}. One goal per session. A later goal resets only this session.`,
|
|
523
|
+
`Add one intent at a time with ${wireName("add_intent")} and exactly one anchor: the goal, or a fact already recorded. Cite the id the tool returns.`,
|
|
524
|
+
`Record evidence with ${wireName("add_fact")} on that intent.`,
|
|
525
|
+
`Record a confirmed issue with ${wireName("add_finding")}. Include a severity and at least one observation of what was seen. Do not put exploit payloads, proof-of-concept code, or attack instructions in the record.`,
|
|
526
|
+
`Record assets with ${wireName("add_asset")}. Record a parent before its children. An empty parent id is a root asset.`,
|
|
527
|
+
`Read the complete store with ${wireName("state")}, ${wireName("graph")}, and ${wireName("report")}. The session panel is a window of at most 200 nodes, assets, and edges.`,
|
|
528
|
+
`A delegated child session may only call ${SUBMIT_WIRE_NAME}, aimed at an intent on its parent session. Decision and read tools are refused there. The parent session cannot call ${SUBMIT_WIRE_NAME}.`,
|
|
529
|
+
"",
|
|
530
|
+
"Edges are spawns (goal to intent), yields (intent to fact), derived_from (fact to intent), proves (intent to finding), and parent (asset to asset).",
|
|
531
|
+
"Respond in the user's language."
|
|
532
|
+
].join("\n");
|
|
533
|
+
function turnContext(input) {
|
|
534
|
+
if (input.target === null) return "Assessment record for this session: no goal yet.";
|
|
535
|
+
const target = input.target.length > 120 ? `${input.target.slice(0, 120)}\u2026` : input.target;
|
|
536
|
+
return `Assessment record: target ${target}. Intents ${String(input.intents)}, evidence ${String(input.facts)}, findings ${String(input.findings)}, assets ${String(input.assets)}.`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/related.ts
|
|
540
|
+
async function relatedSessions(projections, threadId) {
|
|
541
|
+
const current = await projections.getThread(threadId);
|
|
542
|
+
if (current === null) return [];
|
|
543
|
+
const page = await projections.listThreads({ limit: 100, includeArchived: false });
|
|
544
|
+
const found = [];
|
|
545
|
+
for (const shell of page.threads) {
|
|
546
|
+
if (found.length >= 20) break;
|
|
547
|
+
if (shell.projectId !== current.projectId || shell.threadId === threadId) continue;
|
|
548
|
+
const thread = await projections.getThread(shell.threadId);
|
|
549
|
+
if (thread?.origin?.parentThreadId === threadId) {
|
|
550
|
+
found.push({ threadId: shell.threadId, title: shell.title });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return found;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// src/store.ts
|
|
557
|
+
import * as NodeFSP from "node:fs/promises";
|
|
558
|
+
import * as NodePath from "node:path";
|
|
559
|
+
var defaultPrefs = () => ({
|
|
560
|
+
windowLimit: WINDOW_DEFAULT,
|
|
561
|
+
includeAuthorization: true
|
|
562
|
+
});
|
|
563
|
+
function createStore(dataDir) {
|
|
564
|
+
const file = NodePath.join(dataDir, "record.json");
|
|
565
|
+
let chain = Promise.resolve();
|
|
566
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
567
|
+
const exclusive = (run) => {
|
|
568
|
+
const next = chain.then(run, run);
|
|
569
|
+
chain = next.then(
|
|
570
|
+
() => void 0,
|
|
571
|
+
() => void 0
|
|
572
|
+
);
|
|
573
|
+
return next;
|
|
574
|
+
};
|
|
575
|
+
const load = async () => {
|
|
576
|
+
try {
|
|
577
|
+
const parsed = JSON.parse(await NodeFSP.readFile(file, "utf8"));
|
|
578
|
+
return normalizeFile(parsed);
|
|
579
|
+
} catch {
|
|
580
|
+
return { prefs: defaultPrefs(), sessions: {} };
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
const save = async (value) => {
|
|
584
|
+
await NodeFSP.mkdir(dataDir, { recursive: true });
|
|
585
|
+
const temp = `${file}.${String(process.pid)}.tmp`;
|
|
586
|
+
await NodeFSP.writeFile(temp, `${JSON.stringify(value)}
|
|
587
|
+
`);
|
|
588
|
+
await NodeFSP.rename(temp, file);
|
|
589
|
+
};
|
|
590
|
+
return {
|
|
591
|
+
subscribe(listener) {
|
|
592
|
+
listeners.add(listener);
|
|
593
|
+
return () => listeners.delete(listener);
|
|
594
|
+
},
|
|
595
|
+
read(threadId) {
|
|
596
|
+
return exclusive(async () => (await load()).sessions[threadId] ?? emptyEngagement());
|
|
597
|
+
},
|
|
598
|
+
prefs() {
|
|
599
|
+
return exclusive(async () => (await load()).prefs);
|
|
600
|
+
},
|
|
601
|
+
update(threadId, recipe) {
|
|
602
|
+
return exclusive(async () => {
|
|
603
|
+
const current = await load();
|
|
604
|
+
const engagement = recipe(current.sessions[threadId] ?? emptyEngagement());
|
|
605
|
+
await save({ ...current, sessions: { ...current.sessions, [threadId]: engagement } });
|
|
606
|
+
for (const listener of listeners) listener();
|
|
607
|
+
return engagement;
|
|
608
|
+
});
|
|
609
|
+
},
|
|
610
|
+
writePrefs(patch) {
|
|
611
|
+
return exclusive(async () => {
|
|
612
|
+
const current = await load();
|
|
613
|
+
const prefs = {
|
|
614
|
+
windowLimit: patch.windowLimit === void 0 ? current.prefs.windowLimit : clampWindow(patch.windowLimit),
|
|
615
|
+
includeAuthorization: patch.includeAuthorization === void 0 ? current.prefs.includeAuthorization : patch.includeAuthorization
|
|
616
|
+
};
|
|
617
|
+
await save({ ...current, prefs });
|
|
618
|
+
for (const listener of listeners) listener();
|
|
619
|
+
return prefs;
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function normalizeFile(value) {
|
|
625
|
+
if (typeof value !== "object" || value === null) return { prefs: defaultPrefs(), sessions: {} };
|
|
626
|
+
const record = value;
|
|
627
|
+
const prefs = normalizePrefs(record.prefs);
|
|
628
|
+
const sessions = {};
|
|
629
|
+
if (typeof record.sessions === "object" && record.sessions !== null) {
|
|
630
|
+
for (const [threadId, engagement] of Object.entries(record.sessions)) {
|
|
631
|
+
if (isEngagement(engagement)) sessions[threadId] = engagement;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return { prefs, sessions };
|
|
635
|
+
}
|
|
636
|
+
function normalizePrefs(value) {
|
|
637
|
+
if (typeof value !== "object" || value === null) return defaultPrefs();
|
|
638
|
+
const record = value;
|
|
639
|
+
return {
|
|
640
|
+
windowLimit: typeof record.windowLimit === "number" ? clampWindow(record.windowLimit) : WINDOW_DEFAULT,
|
|
641
|
+
includeAuthorization: record.includeAuthorization !== false
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// src/tools.ts
|
|
646
|
+
import {
|
|
647
|
+
pluginJsonByteLength
|
|
648
|
+
} from "@ycodium-ai/plugin-api";
|
|
649
|
+
var saved = (name, id, engagement) => ({
|
|
650
|
+
text: `Recorded ${id}.`,
|
|
651
|
+
presentation: presentationOf(name, id, engagement)
|
|
652
|
+
});
|
|
653
|
+
var failure = (error) => ({ text: error, isError: true });
|
|
654
|
+
function recordTools(service) {
|
|
655
|
+
return [
|
|
656
|
+
tool(
|
|
657
|
+
"add_goal",
|
|
658
|
+
"Start or reset this session's assessment graph. authorization is an audit note, not a permission check.",
|
|
659
|
+
{
|
|
660
|
+
target: { type: "string", description: "What this session is about." },
|
|
661
|
+
objective: { type: "string", description: "What the record should establish." },
|
|
662
|
+
authorization: {
|
|
663
|
+
type: "string",
|
|
664
|
+
description: "Who granted permission, or a written reference. Stored as an audit note."
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
["target", "objective"],
|
|
668
|
+
(args, invocation) => mutate(
|
|
669
|
+
service,
|
|
670
|
+
invocation,
|
|
671
|
+
"add_goal",
|
|
672
|
+
null,
|
|
673
|
+
(current) => addGoal(current, {
|
|
674
|
+
target: args["target"],
|
|
675
|
+
objective: args["objective"],
|
|
676
|
+
authorization: args["authorization"]
|
|
677
|
+
})
|
|
678
|
+
)
|
|
679
|
+
),
|
|
680
|
+
tool(
|
|
681
|
+
"add_intent",
|
|
682
|
+
"Add one intent. Exactly one anchor: anchorKind goal or fact, plus that record's id.",
|
|
683
|
+
{
|
|
684
|
+
anchorKind: { type: "string", enum: ["goal", "fact"] },
|
|
685
|
+
anchorId: { type: "string" },
|
|
686
|
+
title: { type: "string" },
|
|
687
|
+
detail: { type: "string" }
|
|
688
|
+
},
|
|
689
|
+
["anchorKind", "anchorId", "title"],
|
|
690
|
+
(args, invocation) => mutate(
|
|
691
|
+
service,
|
|
692
|
+
invocation,
|
|
693
|
+
"add_intent",
|
|
694
|
+
null,
|
|
695
|
+
(current) => addIntent(current, {
|
|
696
|
+
anchorKind: args["anchorKind"],
|
|
697
|
+
anchorId: args["anchorId"],
|
|
698
|
+
title: args["title"],
|
|
699
|
+
detail: args["detail"]
|
|
700
|
+
})
|
|
701
|
+
)
|
|
702
|
+
),
|
|
703
|
+
tool(
|
|
704
|
+
"add_fact",
|
|
705
|
+
"Record evidence yielded by an intent.",
|
|
706
|
+
{
|
|
707
|
+
intentId: { type: "string" },
|
|
708
|
+
factKind: { type: "string", enum: ["port", "service", "vuln", "finding", "http", "info"] },
|
|
709
|
+
target: { type: "string" },
|
|
710
|
+
detail: { type: "string" },
|
|
711
|
+
confidence: { type: "number", description: "From 0 to 1. Defaults to 0.5." }
|
|
712
|
+
},
|
|
713
|
+
["intentId", "factKind", "detail"],
|
|
714
|
+
(args, invocation) => mutate(
|
|
715
|
+
service,
|
|
716
|
+
invocation,
|
|
717
|
+
"add_fact",
|
|
718
|
+
null,
|
|
719
|
+
(current) => addFact(current, {
|
|
720
|
+
intentId: args["intentId"],
|
|
721
|
+
factKind: args["factKind"],
|
|
722
|
+
target: args["target"],
|
|
723
|
+
detail: args["detail"],
|
|
724
|
+
confidence: args["confidence"]
|
|
725
|
+
})
|
|
726
|
+
)
|
|
727
|
+
),
|
|
728
|
+
tool(
|
|
729
|
+
"add_finding",
|
|
730
|
+
"Record a confirmed issue proved by an intent. steps are observations of what was seen, at least one. This tool does not execute anything.",
|
|
731
|
+
{
|
|
732
|
+
intentId: { type: "string" },
|
|
733
|
+
title: { type: "string" },
|
|
734
|
+
severity: { type: "string", enum: ["critical", "high", "medium", "low", "info"] },
|
|
735
|
+
description: { type: "string" },
|
|
736
|
+
steps: {
|
|
737
|
+
type: "array",
|
|
738
|
+
items: { type: "string" },
|
|
739
|
+
description: "Observations. At least one."
|
|
740
|
+
},
|
|
741
|
+
affectedAssetId: { type: "string" }
|
|
742
|
+
},
|
|
743
|
+
["intentId", "title", "severity", "steps"],
|
|
744
|
+
(args, invocation) => mutate(
|
|
745
|
+
service,
|
|
746
|
+
invocation,
|
|
747
|
+
"add_finding",
|
|
748
|
+
null,
|
|
749
|
+
(current) => addFinding(current, {
|
|
750
|
+
intentId: args["intentId"],
|
|
751
|
+
title: args["title"],
|
|
752
|
+
severity: args["severity"],
|
|
753
|
+
description: args["description"],
|
|
754
|
+
steps: args["steps"],
|
|
755
|
+
affectedAssetId: args["affectedAssetId"]
|
|
756
|
+
})
|
|
757
|
+
)
|
|
758
|
+
),
|
|
759
|
+
tool(
|
|
760
|
+
"add_asset",
|
|
761
|
+
"Record an asset. parentId empty means a root. Record the parent first.",
|
|
762
|
+
{
|
|
763
|
+
type: {
|
|
764
|
+
type: "string",
|
|
765
|
+
enum: ["root-domain", "subdomain", "ip", "service", "app", "endpoint"]
|
|
766
|
+
},
|
|
767
|
+
value: { type: "string" },
|
|
768
|
+
meta: { type: "string" },
|
|
769
|
+
parentId: { type: "string" }
|
|
770
|
+
},
|
|
771
|
+
["type", "value"],
|
|
772
|
+
(args, invocation) => mutate(
|
|
773
|
+
service,
|
|
774
|
+
invocation,
|
|
775
|
+
"add_asset",
|
|
776
|
+
null,
|
|
777
|
+
(current) => addAsset(current, {
|
|
778
|
+
type: args["type"],
|
|
779
|
+
value: args["value"],
|
|
780
|
+
meta: args["meta"],
|
|
781
|
+
parentId: args["parentId"]
|
|
782
|
+
})
|
|
783
|
+
)
|
|
784
|
+
),
|
|
785
|
+
tool(
|
|
786
|
+
"state",
|
|
787
|
+
"Read counts and the goal for this session's full store, not the panel window.",
|
|
788
|
+
{},
|
|
789
|
+
[],
|
|
790
|
+
async (_args, invocation) => {
|
|
791
|
+
const threadId = invocation.threadId;
|
|
792
|
+
if (threadId === null) return failure("no session");
|
|
793
|
+
const engagement = await service.read(threadId);
|
|
794
|
+
const counts = countsOf(engagement);
|
|
795
|
+
return {
|
|
796
|
+
text: engagement.goal === null ? "No goal has been recorded." : `Goal ${engagement.goal.id} on ${engagement.goal.target}. Intents ${String(counts.intents)}, evidence ${String(counts.facts)}, findings ${String(counts.findings)}, assets ${String(counts.assets)}.`,
|
|
797
|
+
presentation: presentationOf("state", engagement.goal?.id ?? "", engagement)
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
),
|
|
801
|
+
tool(
|
|
802
|
+
"graph",
|
|
803
|
+
"Read the full assessment graph for this session.",
|
|
804
|
+
{},
|
|
805
|
+
[],
|
|
806
|
+
async (_args, invocation) => {
|
|
807
|
+
const threadId = invocation.threadId;
|
|
808
|
+
if (threadId === null) return failure("no session");
|
|
809
|
+
const engagement = await service.read(threadId);
|
|
810
|
+
const body = JSON.stringify({
|
|
811
|
+
goal: engagement.goal,
|
|
812
|
+
nodes: engagement.nodes,
|
|
813
|
+
assets: engagement.assets,
|
|
814
|
+
edges: engagement.edges
|
|
815
|
+
});
|
|
816
|
+
const text2 = body.length <= REPORT_TEXT_CAP ? body : `${body.slice(0, REPORT_TEXT_CAP)}
|
|
817
|
+
[truncated]`;
|
|
818
|
+
return {
|
|
819
|
+
text: text2,
|
|
820
|
+
presentation: presentationOf("graph", engagement.goal?.id ?? "", engagement)
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
),
|
|
824
|
+
tool(
|
|
825
|
+
"report",
|
|
826
|
+
"Read a Markdown report of the full store. When the session workspace is writable, also save pentest/report.md.",
|
|
827
|
+
{},
|
|
828
|
+
[],
|
|
829
|
+
async (_args, invocation) => {
|
|
830
|
+
const threadId = invocation.threadId;
|
|
831
|
+
if (threadId === null) return failure("no session");
|
|
832
|
+
const engagement = await service.read(threadId);
|
|
833
|
+
const markdown = reportMarkdown(engagement, await service.includeAuthorization());
|
|
834
|
+
const path = await writeReport(invocation.workspace, markdown);
|
|
835
|
+
const suffix = path === null ? "" : ` Saved ${path}.`;
|
|
836
|
+
return {
|
|
837
|
+
text: `${markdown}${suffix}`,
|
|
838
|
+
presentation: presentationOf("report", path ?? engagement.goal?.id ?? "", engagement)
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
),
|
|
842
|
+
tool(
|
|
843
|
+
SUBMIT_TOOL,
|
|
844
|
+
"Delegated sessions only. Write one fact, asset, or finding onto an intent of the parent session.",
|
|
845
|
+
{
|
|
846
|
+
intentId: { type: "string" },
|
|
847
|
+
recordKind: { type: "string", enum: ["fact", "asset", "finding"] },
|
|
848
|
+
factKind: { type: "string", enum: ["port", "service", "vuln", "finding", "http", "info"] },
|
|
849
|
+
target: { type: "string" },
|
|
850
|
+
detail: { type: "string" },
|
|
851
|
+
confidence: { type: "number" },
|
|
852
|
+
type: {
|
|
853
|
+
type: "string",
|
|
854
|
+
enum: ["root-domain", "subdomain", "ip", "service", "app", "endpoint"]
|
|
855
|
+
},
|
|
856
|
+
value: { type: "string" },
|
|
857
|
+
meta: { type: "string" },
|
|
858
|
+
parentId: { type: "string" },
|
|
859
|
+
title: { type: "string" },
|
|
860
|
+
severity: { type: "string", enum: ["critical", "high", "medium", "low", "info"] },
|
|
861
|
+
description: { type: "string" },
|
|
862
|
+
steps: { type: "array", items: { type: "string" } },
|
|
863
|
+
affectedAssetId: { type: "string" }
|
|
864
|
+
},
|
|
865
|
+
["intentId", "recordKind"],
|
|
866
|
+
async (args, invocation) => {
|
|
867
|
+
const threadId = invocation.threadId;
|
|
868
|
+
if (threadId === null) return failure("no session");
|
|
869
|
+
const parent = await service.parentOf(threadId);
|
|
870
|
+
if (parent === null || parent.length === 0)
|
|
871
|
+
return failure("pentest_submit only runs in a delegated session.");
|
|
872
|
+
const record = submitFrom(args);
|
|
873
|
+
if (record === null) return failure("recordKind is not recognized");
|
|
874
|
+
return mutate(
|
|
875
|
+
service,
|
|
876
|
+
invocation,
|
|
877
|
+
SUBMIT_TOOL,
|
|
878
|
+
parent,
|
|
879
|
+
(current) => applySubmit(current, record)
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
)
|
|
883
|
+
];
|
|
884
|
+
}
|
|
885
|
+
function tool(name, description, properties, required, handle) {
|
|
886
|
+
return {
|
|
887
|
+
name,
|
|
888
|
+
description,
|
|
889
|
+
...name === SUBMIT_TOOL ? {} : { whenPresetId: PRESET_ID },
|
|
890
|
+
inputSchema: {
|
|
891
|
+
type: "object",
|
|
892
|
+
properties,
|
|
893
|
+
...required.length > 0 ? { required: [...required] } : {}
|
|
894
|
+
},
|
|
895
|
+
handle
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
async function mutate(service, invocation, name, threadId, recipe) {
|
|
899
|
+
const id = threadId ?? invocation.threadId;
|
|
900
|
+
if (id === null) return failure("no session");
|
|
901
|
+
const recorded = { current: null };
|
|
902
|
+
const engagement = await service.update(id, (current) => {
|
|
903
|
+
const next = recipe(current);
|
|
904
|
+
recorded.current = next;
|
|
905
|
+
return next.ok ? next.engagement : current;
|
|
906
|
+
});
|
|
907
|
+
const outcome = recorded.current;
|
|
908
|
+
if (outcome === null || !outcome.ok)
|
|
909
|
+
return failure(outcome === null ? "not recorded" : outcome.error);
|
|
910
|
+
await service.publish(id);
|
|
911
|
+
return saved(name, outcome.id, engagement);
|
|
912
|
+
}
|
|
913
|
+
function presentationOf(toolName, id, engagement) {
|
|
914
|
+
const counts = countsOf(engagement);
|
|
915
|
+
const body = {
|
|
916
|
+
kind: "pentest",
|
|
917
|
+
tool: toolName,
|
|
918
|
+
id,
|
|
919
|
+
counts: {
|
|
920
|
+
intents: counts.intents,
|
|
921
|
+
facts: counts.facts,
|
|
922
|
+
findings: counts.findings,
|
|
923
|
+
assets: counts.assets
|
|
924
|
+
},
|
|
925
|
+
target: engagement.goal?.target ?? ""
|
|
926
|
+
};
|
|
927
|
+
if (pluginJsonByteLength(body) > 1024 * 1024) return null;
|
|
928
|
+
return body;
|
|
929
|
+
}
|
|
930
|
+
async function writeReport(workspace, markdown) {
|
|
931
|
+
if (workspace?.mkdir === void 0 || workspace.writeFile === void 0) return null;
|
|
932
|
+
try {
|
|
933
|
+
await workspace.mkdir("pentest");
|
|
934
|
+
await workspace.writeFile("pentest/report.md", new TextEncoder().encode(markdown));
|
|
935
|
+
return "pentest/report.md";
|
|
936
|
+
} catch {
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
function submitFrom(args) {
|
|
941
|
+
const intentId = typeof args["intentId"] === "string" ? args["intentId"] : "";
|
|
942
|
+
const kind = args["recordKind"];
|
|
943
|
+
if (kind === "fact") {
|
|
944
|
+
return {
|
|
945
|
+
recordKind: "fact",
|
|
946
|
+
intentId,
|
|
947
|
+
factKind: args["factKind"],
|
|
948
|
+
target: args["target"],
|
|
949
|
+
detail: args["detail"],
|
|
950
|
+
confidence: args["confidence"]
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
if (kind === "asset") {
|
|
954
|
+
return {
|
|
955
|
+
recordKind: "asset",
|
|
956
|
+
intentId,
|
|
957
|
+
type: args["type"],
|
|
958
|
+
value: args["value"],
|
|
959
|
+
meta: args["meta"],
|
|
960
|
+
parentId: args["parentId"]
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
if (kind === "finding") {
|
|
964
|
+
return {
|
|
965
|
+
recordKind: "finding",
|
|
966
|
+
intentId,
|
|
967
|
+
title: args["title"],
|
|
968
|
+
severity: args["severity"],
|
|
969
|
+
description: args["description"],
|
|
970
|
+
steps: args["steps"],
|
|
971
|
+
affectedAssetId: args["affectedAssetId"]
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// src/index.ts
|
|
978
|
+
var pentestPlugin = definePlugin({
|
|
979
|
+
id: "pentest",
|
|
980
|
+
apiVersion: 4,
|
|
981
|
+
activate(ctx) {
|
|
982
|
+
const store = createStore(ctx.paths.dataDir);
|
|
983
|
+
ctx.sessionProjections.register({ name: GRAPH_PROJECTION });
|
|
984
|
+
ctx.sessionProjections.register({ name: MOUNTED_PROJECTION });
|
|
985
|
+
ctx.systemPrompt.context(async (turn) => {
|
|
986
|
+
if (turn.presetId !== PRESET_ID) return null;
|
|
987
|
+
try {
|
|
988
|
+
const engagement = await store.read(turn.threadId);
|
|
989
|
+
if (engagement.goal === null) return null;
|
|
990
|
+
const counts = countsOf(engagement);
|
|
991
|
+
return turnContext({
|
|
992
|
+
target: engagement.goal.target,
|
|
993
|
+
intents: counts.intents,
|
|
994
|
+
facts: counts.facts,
|
|
995
|
+
findings: counts.findings,
|
|
996
|
+
assets: counts.assets
|
|
997
|
+
});
|
|
998
|
+
} catch (error) {
|
|
999
|
+
ctx.log.warn("pentest turn context skipped", {
|
|
1000
|
+
detail: error instanceof Error ? error.message : "unknown"
|
|
1001
|
+
});
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
ctx.tools.guard(
|
|
1006
|
+
(input) => decideRecordGuard(input.name, input.threadId, (threadId) => parentOf(ctx, threadId))
|
|
1007
|
+
);
|
|
1008
|
+
ctx.agentPresets.register({
|
|
1009
|
+
id: PRESET_ID,
|
|
1010
|
+
displayName: "Assessment record",
|
|
1011
|
+
description: en_default.preset.description,
|
|
1012
|
+
promptPrefix: PROTOCOL_TEXT
|
|
1013
|
+
});
|
|
1014
|
+
const service = createService(ctx, store);
|
|
1015
|
+
ctx.toolkit({ name: "record", tools: recordTools(service) });
|
|
1016
|
+
ctx.command({
|
|
1017
|
+
name: "board",
|
|
1018
|
+
description: "Read one session's assessment record.",
|
|
1019
|
+
scope: "read",
|
|
1020
|
+
payloadSchema: {
|
|
1021
|
+
type: "object",
|
|
1022
|
+
properties: { threadId: { type: "string" } },
|
|
1023
|
+
required: ["threadId"]
|
|
1024
|
+
},
|
|
1025
|
+
handle: (payload) => snapshot(ctx, store, readString(payload["threadId"]))
|
|
1026
|
+
});
|
|
1027
|
+
ctx.command({
|
|
1028
|
+
name: "report",
|
|
1029
|
+
description: "Read the full Markdown report for one session.",
|
|
1030
|
+
scope: "read",
|
|
1031
|
+
payloadSchema: {
|
|
1032
|
+
type: "object",
|
|
1033
|
+
properties: { threadId: { type: "string" } },
|
|
1034
|
+
required: ["threadId"]
|
|
1035
|
+
},
|
|
1036
|
+
handle: async (payload) => {
|
|
1037
|
+
const threadId = readString(payload["threadId"]);
|
|
1038
|
+
const engagement = await store.read(threadId);
|
|
1039
|
+
const prefs = await store.prefs();
|
|
1040
|
+
return { markdown: reportMarkdown(engagement, prefs.includeAuthorization) };
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
ctx.command({
|
|
1044
|
+
name: "preferences",
|
|
1045
|
+
description: "Update the panel window and whether reports include the authorization note.",
|
|
1046
|
+
scope: "operate",
|
|
1047
|
+
payloadSchema: {
|
|
1048
|
+
type: "object",
|
|
1049
|
+
properties: {
|
|
1050
|
+
threadId: { type: "string" },
|
|
1051
|
+
windowLimit: { type: "number" },
|
|
1052
|
+
includeAuthorization: { type: "boolean" }
|
|
1053
|
+
}
|
|
1054
|
+
},
|
|
1055
|
+
handle: async (payload) => {
|
|
1056
|
+
const prefs = await store.writePrefs({
|
|
1057
|
+
...typeof payload["windowLimit"] === "number" ? { windowLimit: payload["windowLimit"] } : {},
|
|
1058
|
+
...typeof payload["includeAuthorization"] === "boolean" ? { includeAuthorization: payload["includeAuthorization"] } : {}
|
|
1059
|
+
});
|
|
1060
|
+
const threadId = readString(payload["threadId"]);
|
|
1061
|
+
if (threadId.length > 0) await service.publish(threadId);
|
|
1062
|
+
return { prefs };
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
ctx.stream({
|
|
1066
|
+
name: "live",
|
|
1067
|
+
description: "Follow one session's assessment record.",
|
|
1068
|
+
scope: "read",
|
|
1069
|
+
buffer: { latest: 1 },
|
|
1070
|
+
payloadSchema: {
|
|
1071
|
+
type: "object",
|
|
1072
|
+
properties: { threadId: { type: "string" } },
|
|
1073
|
+
required: ["threadId"]
|
|
1074
|
+
},
|
|
1075
|
+
handle: async (payload, emit, signal) => {
|
|
1076
|
+
const threadId = readString(payload["threadId"]);
|
|
1077
|
+
const push = () => {
|
|
1078
|
+
void snapshot(ctx, store, threadId).then((value) => emit(value));
|
|
1079
|
+
};
|
|
1080
|
+
push();
|
|
1081
|
+
const unsubscribe = store.subscribe(push);
|
|
1082
|
+
try {
|
|
1083
|
+
await abort(signal);
|
|
1084
|
+
} finally {
|
|
1085
|
+
unsubscribe();
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
ctx.settingsCard({
|
|
1090
|
+
titleKey: "settings.card.title",
|
|
1091
|
+
descriptionKey: "settings.card.description",
|
|
1092
|
+
fields: []
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
function createService(ctx, store) {
|
|
1097
|
+
return {
|
|
1098
|
+
read: (threadId) => store.read(threadId),
|
|
1099
|
+
update: (threadId, recipe) => store.update(threadId, recipe),
|
|
1100
|
+
includeAuthorization: async () => (await store.prefs()).includeAuthorization,
|
|
1101
|
+
publish: (threadId) => publish(ctx, store, threadId),
|
|
1102
|
+
parentOf: (threadId) => parentOf(ctx, threadId)
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
async function publish(ctx, store, threadId) {
|
|
1106
|
+
const engagement = await store.read(threadId);
|
|
1107
|
+
const prefs = await store.prefs();
|
|
1108
|
+
const mounted = engagement.goal !== null;
|
|
1109
|
+
await ctx.sessionProjections.set({
|
|
1110
|
+
name: MOUNTED_PROJECTION,
|
|
1111
|
+
threadId,
|
|
1112
|
+
value: mounted
|
|
1113
|
+
});
|
|
1114
|
+
await ctx.sessionProjections.set({
|
|
1115
|
+
name: GRAPH_PROJECTION,
|
|
1116
|
+
threadId,
|
|
1117
|
+
value: mounted ? jsonValue(windowOf(engagement, prefs.windowLimit)) : null
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
async function snapshot(ctx, store, threadId) {
|
|
1121
|
+
const engagement = threadId.length === 0 ? emptyEngagement() : await store.read(threadId);
|
|
1122
|
+
const prefs = await store.prefs();
|
|
1123
|
+
let related = [];
|
|
1124
|
+
if (threadId.length > 0) {
|
|
1125
|
+
try {
|
|
1126
|
+
related = await relatedSessions(ctx.projections, threadId);
|
|
1127
|
+
} catch (error) {
|
|
1128
|
+
ctx.log.warn("pentest related sessions skipped", {
|
|
1129
|
+
detail: error instanceof Error ? error.message : "unknown"
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return {
|
|
1134
|
+
mounted: engagement.goal !== null,
|
|
1135
|
+
graph: engagement.goal === null ? null : windowOf(engagement, prefs.windowLimit),
|
|
1136
|
+
related,
|
|
1137
|
+
prefs,
|
|
1138
|
+
report: reportMarkdown(engagement, prefs.includeAuthorization)
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
async function parentOf(ctx, threadId) {
|
|
1142
|
+
const thread = await ctx.projections.getThread(threadId);
|
|
1143
|
+
const parent = thread?.origin?.parentThreadId;
|
|
1144
|
+
return parent === void 0 || parent.length === 0 ? null : parent;
|
|
1145
|
+
}
|
|
1146
|
+
function jsonValue(value) {
|
|
1147
|
+
return JSON.parse(JSON.stringify(value));
|
|
1148
|
+
}
|
|
1149
|
+
function readString(value) {
|
|
1150
|
+
return typeof value === "string" ? value : "";
|
|
1151
|
+
}
|
|
1152
|
+
function abort(signal) {
|
|
1153
|
+
return new Promise((resolve) => {
|
|
1154
|
+
if (signal.aborted) {
|
|
1155
|
+
resolve();
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
var index_default = pentestPlugin;
|
|
1162
|
+
export {
|
|
1163
|
+
index_default as default
|
|
1164
|
+
};
|