@bugb-technologies/bravos 0.0.0-stage → 1.0.1

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 (85) hide show
  1. package/LICENSE +70 -0
  2. package/README.md +225 -2
  3. package/dist/account.js +140 -0
  4. package/dist/artifacts.js +450 -0
  5. package/dist/ci.js +52 -0
  6. package/dist/cli.js +963 -0
  7. package/dist/codegraph.js +268 -0
  8. package/dist/cursor.js +70 -0
  9. package/dist/detect.js +26 -0
  10. package/dist/doctor.js +99 -0
  11. package/dist/global.js +95 -0
  12. package/dist/graphbin.js +136 -0
  13. package/dist/guardlink.js +123 -0
  14. package/dist/guide.js +255 -0
  15. package/dist/help.js +334 -0
  16. package/dist/hooks.js +167 -0
  17. package/dist/install.js +209 -0
  18. package/dist/licence.js +41 -0
  19. package/dist/lock.js +50 -0
  20. package/dist/mcp.js +139 -0
  21. package/dist/migrate.js +275 -0
  22. package/dist/omp.js +46 -0
  23. package/dist/opencode.js +118 -0
  24. package/dist/paths.js +33 -0
  25. package/dist/plugin.js +101 -0
  26. package/dist/report/check.js +252 -0
  27. package/dist/report/data.js +401 -0
  28. package/dist/report/export.js +105 -0
  29. package/dist/report/markdown.js +310 -0
  30. package/dist/report/meta.js +48 -0
  31. package/dist/report/pdf.js +96 -0
  32. package/dist/review-server.js +7 -0
  33. package/dist/review.js +274 -0
  34. package/dist/setup.js +133 -0
  35. package/dist/skills.js +81 -0
  36. package/dist/types.js +9 -0
  37. package/dist/uninstall.js +192 -0
  38. package/dist/versions.js +8 -0
  39. package/dist/watch-frame.js +593 -0
  40. package/dist/watch.js +873 -0
  41. package/dist/workspace.js +316 -0
  42. package/docs/GUIDE.md +323 -0
  43. package/hooks/autopilot.mjs +45 -0
  44. package/hooks/lib/autopilot.mjs +191 -0
  45. package/hooks/lib/briefing.mjs +750 -0
  46. package/hooks/lib/licence.mjs +452 -0
  47. package/hooks/lib/pre-pr.mjs +209 -0
  48. package/hooks/lib/sast.mjs +207 -0
  49. package/hooks/lib/setup.mjs +107 -0
  50. package/hooks/lib/workspace.mjs +78 -0
  51. package/hooks/pre-pr.mjs +54 -0
  52. package/hooks/session-start.mjs +82 -0
  53. package/package.json +49 -4
  54. package/skills/bravos/SKILL.md +63 -0
  55. package/skills/bravos-annotate/SKILL.md +56 -0
  56. package/skills/bravos-annotate/scripts/lib/pick.mjs +127 -0
  57. package/skills/bravos-annotate/scripts/slice.mjs +141 -0
  58. package/skills/bravos-campaign/SKILL.md +49 -0
  59. package/skills/bravos-cicd/SKILL.md +48 -0
  60. package/skills/bravos-codegraph/SKILL.md +57 -0
  61. package/skills/bravos-context/SKILL.md +51 -0
  62. package/skills/bravos-context/intent.example.md +48 -0
  63. package/skills/bravos-env/SKILL.md +48 -0
  64. package/skills/bravos-env/scope.example.yaml +50 -0
  65. package/skills/bravos-prove/SKILL.md +52 -0
  66. package/skills/bravos-recheck/SKILL.md +47 -0
  67. package/skills/bravos-report/SKILL.md +53 -0
  68. package/skills/bravos-report/assessment.md +54 -0
  69. package/skills/bravos-report/brief.md +23 -0
  70. package/skills/bravos-report/finding.md +74 -0
  71. package/skills/bravos-report/retest.md +29 -0
  72. package/skills/bravos-report/style.md +44 -0
  73. package/skills/bravos-sast/SKILL.md +48 -0
  74. package/skills/bravos-schedule/SKILL.md +44 -0
  75. package/skills/bravos-schedule/loop.md +16 -0
  76. package/skills/bravos-ship/SKILL.md +58 -0
  77. package/skills/bravos-templatize/SKILL.md +48 -0
  78. package/skills/bravos-templatize/operator-guidance.md +20 -0
  79. package/templates/omp/bravos.js +62 -0
  80. package/templates/opencode/bravos.js +33 -0
  81. package/templates/report/report.html +88 -0
  82. package/templates/review/review.html +327 -0
  83. package/templates/workflows/bravos-nightly-claude.yml +173 -0
  84. package/templates/workflows/bravos-nightly-codex.yml +172 -0
  85. package/templates/workflows/bravos-pr-gate.yml +142 -0
@@ -0,0 +1,252 @@
1
+ import { LEVEL_LABEL, SECRET_PATTERNS } from './data.js';
2
+ const LEVEL_RANK = { reproduced: 0, held: 1, 'code-review': 2, suspected: 3 };
3
+ // @comment -- "bravos report check: the deterministic gate on a report.md the agent wrote. It reads the headings and field labels the bravos-report templates define and fails on a missing or out-of-order section, a finding missing a field, a missing or duplicate F-id, an evidence label stronger than the cited evidence supports, a cited id the bundle does not hold, a percentage without its denominator, a secret-shaped string, or a CVSS score without its vector; it warns on long paragraphs and vague titles. Pure: text and bundle in, a verdict out."
4
+ export const SECTIONS = {
5
+ assessment: [
6
+ 'executive assessment',
7
+ 'scope and limitations',
8
+ 'system and threat context',
9
+ 'coverage and approach',
10
+ 'principal attack narratives',
11
+ 'findings',
12
+ 'remediation roadmap',
13
+ 'retest results and appendices',
14
+ ],
15
+ brief: ['executive assessment', 'main risks', 'decisions needed', 'what this rests on'],
16
+ retest: ['retest summary', 'original finding', 'retest', 'residual exposure'],
17
+ finding: [],
18
+ };
19
+ /**
20
+ * The parts every finding must carry. Each is satisfied by a bold field (**Label:**) or by a heading inside the
21
+ * finding whose text contains one of the words, so a finding can be written as labelled fields or as a plain,
22
+ * team-readable write-up with headings ("What the attacker needs", "Reproduce it by hand", "How to fix it").
23
+ */
24
+ export const FINDING_PARTS = [
25
+ { name: 'prerequisites', fields: ['Prerequisites'], headings: ['prerequisite', 'need', 'attacker'] },
26
+ { name: 'reproduction steps', fields: ['Reproduction', 'Steps to reproduce'], headings: ['reproduc'] },
27
+ { name: 'root cause', fields: ['Root cause'], headings: ['root cause', 'what we found', 'cause'] },
28
+ { name: 'impact', fields: ['Impact'], headings: ['impact'] },
29
+ { name: 'remediation', fields: ['Remediation', 'Fix'], headings: ['fix', 'remediat'] },
30
+ { name: 'verification', fields: ['Verification'], headings: ['verif', 'check the fix', 'check that the fix', 'how to check'] },
31
+ ];
32
+ /** Kept for compatibility: the classic field labels. */
33
+ export const FINDING_FIELDS = ['Evidence', 'Severity', 'Prerequisites', 'Reproduction', 'Root cause', 'Impact', 'Remediation', 'Verification'];
34
+ const STATUS_FIELDS = ['Status', 'Evidence'];
35
+ export const RETEST_FIELDS = ['Retested build', 'Replay', 'Result', 'Bypass variants', 'Legitimate behaviour'];
36
+ const SEVERITIES = ['critical', 'high', 'medium', 'low', 'informational', 'info'];
37
+ /** Ids a report may cite: EXP-n, LED-<hex>, SAST-n, CTL-n, BND-n, ACC-n, RUN-n and RUN-n/F|X|H|N-m, EDGE-n and UNR-n; in a workspace report each may carry its service as a prefix (orders:EXP-3). */
38
+ export const ID_RE = /(?<![\w:-])(?:[a-z0-9][a-z0-9._-]{0,40}:)?(?:RUN-\d+(?:\/[FXHN]-\d+)?|(?:EXP|SAST|CTL|BND|ACC|EDGE|UNR)-\d+|LED-[a-f0-9]{8})\b/g;
39
+ const FINDING_HEADING = /^(#{2,4})\s+(F-\d{2,3})\s*(?:—|–|-|:)\s*(.+?)\s*$/;
40
+ const norm = (h) => h.replace(/^\d+\.\s*/, '').trim().toLowerCase();
41
+ /** The value of a `**Label:**` / `**Label**:` / `- **Label:**` line, or a `| Label | value |` table row, within lines; null when absent. */
42
+ function field(lines, label) {
43
+ const l = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
44
+ const bold = new RegExp(`^\\s*(?:[-*]\\s+)?\\*\\*${l}(?::\\*\\*|\\*\\*\\s*:)\\s*(.*)$`, 'i');
45
+ const row = new RegExp(`^\\s*\\|\\s*\\**${l}\\**\\s*\\|\\s*(.*?)\\s*\\|?\\s*$`, 'i');
46
+ for (let i = 0; i < lines.length; i++) {
47
+ const m = bold.exec(lines[i]) ?? row.exec(lines[i]);
48
+ if (m) {
49
+ // A label alone on its line (e.g. Reproduction) takes the following lines as its value.
50
+ let value = m[1].trim();
51
+ for (let j = i + 1; !value && j < lines.length && !/^\s*(?:\*\*|#|\|)/.test(lines[j]); j++)
52
+ value = lines[j].trim();
53
+ return { value, line: i };
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+ // @comment -- "Reads an evidence value: which of the four labels it starts with (word for word, any case), and the ids it cites."
59
+ export function parseEvidence(value) {
60
+ const v = value.trim().toLowerCase();
61
+ const level = Object.keys(LEVEL_LABEL).find((k) => v.startsWith(LEVEL_LABEL[k])) ?? null;
62
+ return { level, ids: [...new Set(value.match(ID_RE) ?? [])] };
63
+ }
64
+ // @comment -- "Checks one report against its bundle; see the module comment for the rules."
65
+ export function checkReport(md, bundle, kind) {
66
+ const errors = [];
67
+ const warnings = [];
68
+ const lines = md.split('\n');
69
+ const supports = new Map(bundle.items.map((i) => [i.id, i.supports]));
70
+ const err = (rule, message, line = null) => errors.push({ rule, message, line: line === null ? null : line + 1 });
71
+ const warn = (rule, message, line = null) => warnings.push({ rule, message, line: line === null ? null : line + 1 });
72
+ // Code fences are kept for the secret scan but skipped for prose rules.
73
+ const inFence = [];
74
+ let fence = false;
75
+ for (const l of lines) {
76
+ if (/^\s*```/.test(l)) {
77
+ inFence.push(true);
78
+ fence = !fence;
79
+ continue;
80
+ }
81
+ inFence.push(fence);
82
+ }
83
+ const titleLine = lines.findIndex((l, i) => !inFence[i] && /^#\s+\S/.test(l));
84
+ const title = titleLine >= 0 ? lines[titleLine].replace(/^#\s+/, '').trim() : null;
85
+ if (!title)
86
+ err('title', 'the report has no "# " title line');
87
+ // Sections, in order.
88
+ const headings = lines.map((l, i) => (!inFence[i] && /^##\s+/.test(l) && !FINDING_HEADING.test(l) ? { text: norm(l.replace(/^##\s+/, '')), line: i } : null)).filter((h) => !!h);
89
+ let at = -1;
90
+ for (const want of SECTIONS[kind]) {
91
+ const found = headings.find((h) => h.text === want);
92
+ if (!found) {
93
+ err('section', `missing section "## ${want[0].toUpperCase()}${want.slice(1)}"`);
94
+ continue;
95
+ }
96
+ if (found.line < at)
97
+ err('section-order', `section "${want}" is out of order`, found.line);
98
+ at = found.line;
99
+ }
100
+ // Findings: "### F-nn — title" blocks; a finding report may instead be one plain write-up (the whole document).
101
+ const fStarts = lines.map((l, i) => (!inFence[i] ? FINDING_HEADING.exec(l) : null) && i).filter((i) => typeof i === 'number');
102
+ const findings = [];
103
+ const seen = new Set();
104
+ const headingIn = (block, from, words) => block.some((l, i) => !inFence[from + i] && /^#{2,6}\s/.test(l) && words.some((w) => l.toLowerCase().includes(w)));
105
+ // Validates one finding's lines (block starts at line `from` of the document).
106
+ const checkFinding = (id, ftitle, block, from, at) => {
107
+ if (ftitle.split(/\s+/).length < 5)
108
+ warn('finding-title', `${id}'s title is short; say who can do what to what`, at);
109
+ // Status: every **Status:** / **Evidence:** line must start with an evidence label; a level above "suspected"
110
+ // must be supported by an id cited on one of those lines (a short status line may defer to the Evidence section).
111
+ const statusLines = [];
112
+ block.forEach((l, i) => {
113
+ if (inFence[from + i])
114
+ return;
115
+ for (const f of STATUS_FIELDS) {
116
+ const v = field([l], f);
117
+ if (v)
118
+ statusLines.push({ value: v.value, line: from + i });
119
+ }
120
+ });
121
+ let level = null;
122
+ let evidenceIds = [];
123
+ if (!statusLines.length)
124
+ err('finding-field', `${id} has no "**Status:**" (or "**Evidence:**") line naming its evidence level`, at);
125
+ for (const s of statusLines) {
126
+ const p = parseEvidence(s.value);
127
+ if (!p.level)
128
+ err('evidence-label', `${id}'s status does not start with one of: ${Object.values(LEVEL_LABEL).join('; ')}`, s.line);
129
+ else if (!level || LEVEL_RANK[p.level] < LEVEL_RANK[level])
130
+ level = p.level;
131
+ evidenceIds = [...new Set([...evidenceIds, ...p.ids])];
132
+ }
133
+ if (level && level !== 'suspected') {
134
+ if (!evidenceIds.length)
135
+ err('evidence-ids', `${id} says "${LEVEL_LABEL[level]}" but cites no evidence id on a Status or Evidence line`, statusLines[0]?.line ?? at);
136
+ else if (!evidenceIds.some((x) => supports.get(x)?.includes(level)))
137
+ err('evidence-level', `${id} says "${LEVEL_LABEL[level]}" but none of ${evidenceIds.join(', ')} supports it; use the level the bundle computed`, statusLines[0]?.line ?? at);
138
+ }
139
+ const sev = field(block, 'Severity');
140
+ const sevWord = sev ? (sev.value.toLowerCase().match(/[a-z]+/)?.[0] ?? '') : '';
141
+ if (!sev)
142
+ err('finding-field', `${id} has no "**Severity:**" line`, at);
143
+ else if (!SEVERITIES.includes(sevWord))
144
+ err('severity', `${id}'s severity "${sev.value}" is not one of critical, high, medium, low, informational`, from + sev.line);
145
+ for (const part of FINDING_PARTS) {
146
+ if (!part.fields.some((f) => field(block, f)) && !headingIn(block, from, part.headings))
147
+ err('finding-field', `${id} says nothing under ${part.name}: add a "**${part.fields[0]}:**" line or a heading about it`, at);
148
+ }
149
+ // Impact must say what was shown and what was not.
150
+ const impField = field(block, 'Impact');
151
+ const impHead = block.findIndex((l, i) => !inFence[from + i] && /^#{2,6}\s/.test(l) && l.toLowerCase().includes('impact'));
152
+ const impStart = impField ? impField.line : impHead;
153
+ if (impStart >= 0) {
154
+ let impEnd = block.length;
155
+ for (let i = impStart + 1; i < block.length; i++)
156
+ if (/^#{2,6}\s/.test(block[i]) || (impField && /^\s*\*\*[^*]+:\*\*/.test(block[i]) && !/^\s*\*\*(?:What|Demonstrated|Supported|Uncertain)/i.test(block[i]))) {
157
+ impEnd = i;
158
+ break;
159
+ }
160
+ const text = block.slice(impStart, impField ? Math.min(impEnd, impStart + 8) : impEnd).join(' ').toLowerCase();
161
+ if (!/demonstrated|we showed|what we showed|reproduced/.test(text))
162
+ err('impact', `${id}'s impact does not say what was demonstrated`, from + impStart);
163
+ if (!/uncertain|did not|not run|not shown|not demonstrated|not established|limited/.test(text))
164
+ err('impact', `${id}'s impact does not say what remains uncertain or was not shown`, from + impStart);
165
+ }
166
+ findings.push({ id, title: ftitle, level, severity: sev ? sevWord : null, priority: field(block, 'Priority')?.value.match(/P\d/i)?.[0].toUpperCase() ?? null, evidence: evidenceIds, line: at + 1 });
167
+ };
168
+ fStarts.forEach((start, k) => {
169
+ const m = FINDING_HEADING.exec(lines[start]);
170
+ const depth = m[1].length;
171
+ let end = lines.length;
172
+ for (let i = start + 1; i < lines.length; i++) {
173
+ const h = /^(#{1,6})\s/.exec(lines[i]);
174
+ if (!inFence[i] && h && (h[1].length <= depth || FINDING_HEADING.test(lines[i]))) {
175
+ end = i;
176
+ break;
177
+ }
178
+ }
179
+ if (k + 1 < fStarts.length)
180
+ end = Math.min(end, fStarts[k + 1]);
181
+ const id = m[2];
182
+ if (seen.has(id))
183
+ err('finding-id', `${id} is used twice`, start);
184
+ seen.add(id);
185
+ checkFinding(id, m[3], lines.slice(start + 1, end), start + 1, start);
186
+ });
187
+ // A finding report with no F-nn block is one write-up: the title is the finding, the document its body.
188
+ if (kind === 'finding' && fStarts.length === 0 && title)
189
+ checkFinding('F-01', title, lines.slice(titleLine + 1), titleLine + 1, titleLine);
190
+ if (kind === 'finding' && findings.length !== 1)
191
+ err('finding-count', `a finding report holds one finding (one "### F-nn — title" block, or none and the title is the finding); this one has ${findings.length}`);
192
+ if (kind === 'retest') {
193
+ if (findings.length < 1)
194
+ err('finding-count', 'a retest report quotes its original finding as an F-nn block');
195
+ const retestAt = headings.find((h) => h.text === 'retest');
196
+ const nextAt = headings.find((h) => retestAt && h.line > retestAt.line);
197
+ if (retestAt) {
198
+ const block = lines.slice(retestAt.line + 1, nextAt ? nextAt.line : lines.length);
199
+ for (const f of RETEST_FIELDS)
200
+ if (!field(block, f))
201
+ err('retest-field', `the Retest section has no "**${f}:**" field`, retestAt.line);
202
+ const res = field(block, 'Result');
203
+ if (res) {
204
+ const p = parseEvidence(res.value);
205
+ if (!p.level)
206
+ err('evidence-label', 'the retest Result does not start with one of the four evidence labels', retestAt.line + 1 + res.line);
207
+ else if (p.level !== 'suspected' && !p.ids.some((x) => supports.get(x)?.includes(p.level)))
208
+ err('evidence-level', `the retest Result says "${LEVEL_LABEL[p.level]}" but no cited id supports it`, retestAt.line + 1 + res.line);
209
+ }
210
+ }
211
+ }
212
+ // Line rules: cited ids exist, percentages carry a denominator, CVSS carries its vector.
213
+ lines.forEach((l, i) => {
214
+ for (const x of l.match(ID_RE) ?? [])
215
+ if (!supports.has(x))
216
+ err('unknown-id', `${x} is not in evidence.json`, i);
217
+ if (inFence[i])
218
+ return;
219
+ if (/\b\d{1,3}(?:\.\d+)?\s?%/.test(l) && !/\b[\d,]+\s*(?:\/|of|out of)\s*[\d,]+\b/i.test(l))
220
+ err('denominator', 'a percentage without its denominator (write "83%, 1,417 of 1,712 files")', i);
221
+ if (/\bCVSS\b/i.test(l) && /\b(?:10(?:\.0)?|\d\.\d)\b/.test(l.replace(/CVSS:\d\.\d/gi, '')) && !/CVSS:[234]\.\d\/[A-Z]{1,3}:/.test(l))
222
+ err('cvss', 'a CVSS score without its version and vector (CVSS:4.0/AV:…)', i);
223
+ });
224
+ // Secrets anywhere, code included.
225
+ for (const p of SECRET_PATTERNS) {
226
+ for (const m of md.matchAll(new RegExp(p.re.source, p.re.flags.includes('g') ? p.re.flags : p.re.flags + 'g'))) {
227
+ const line = md.slice(0, m.index).split('\n').length - 1;
228
+ err('secret', `looks like a ${p.name}; redact it and cite where it is instead`, line);
229
+ }
230
+ }
231
+ // Paragraph length.
232
+ let para = [];
233
+ let paraStart = 0;
234
+ const flush = () => {
235
+ const words = para.join(' ').split(/\s+/).filter(Boolean).length;
236
+ if (words > 120)
237
+ warn('paragraph', `a ${words}-word paragraph; keep paragraphs to three to five sentences`, paraStart);
238
+ para = [];
239
+ };
240
+ lines.forEach((l, i) => {
241
+ const prose = !inFence[i] && l.trim() && !/^\s*(?:#|\||[-*]\s|\d+\.\s|>|\*\*[^*]+:\*\*)/.test(l);
242
+ if (prose) {
243
+ if (!para.length)
244
+ paraStart = i;
245
+ para.push(l);
246
+ }
247
+ else
248
+ flush();
249
+ });
250
+ flush();
251
+ return { ok: errors.length === 0, title, errors, warnings, findings };
252
+ }
@@ -0,0 +1,401 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { basename, join, relative } from 'node:path';
4
+ import { graphBin } from '../codegraph.js';
5
+ import { version } from '../paths.js';
6
+ import { graphDetail, lib, parseJson, runner } from '../watch.js';
7
+ import { BravosError } from '../types.js';
8
+ import { META_SCHEMA, REPORTS_DIR, reportDir, writeMeta } from './meta.js';
9
+ export { META_SCHEMA, REPORTS_DIR, listReports, readMeta, reportDir, resolveReport, writeMeta } from './meta.js';
10
+ // @comment -- "bravos report data: gathers the facts a white-box VAPT write-up may cite into one evidence bundle (.bravos/reports/<id>/evidence.json): the threat model, the hypothesis and boundary ledgers, every cxg run under .guardlink/pentest-findings, SAST, the code graph, intent, scope, proving and versions. Every fact gets a stable id, and each exposure's evidence level is computed here from the ledger, runs and SAST, never chosen by the model that writes the prose. Read-only towards the repository except the report folder it creates."
11
+ export const EVIDENCE_SCHEMA = 'bravos.report-evidence/v1';
12
+ export const REPORT_KINDS = ['assessment', 'finding', 'retest', 'brief'];
13
+ export const LEVEL_LABEL = {
14
+ reproduced: 'reproduced at runtime',
15
+ held: 'control held at runtime',
16
+ 'code-review': 'established by code or configuration review',
17
+ suspected: 'suspected, awaiting validation',
18
+ };
19
+ const arr = (v) => (Array.isArray(v) ? v : []);
20
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
21
+ const strip = (s) => String(s ?? '').replace(/^#/, '');
22
+ /** Secret-shaped strings: JWTs, bearer tokens, AWS keys, private keys, key=value credentials. Shared with the report check. */
23
+ export const SECRET_PATTERNS = [
24
+ { name: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
25
+ { name: 'bearer token', re: /\bBearer\s+(?!<)[A-Za-z0-9._~+/-]{20,}=*/g },
26
+ { name: 'aws access key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
27
+ { name: 'private key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
28
+ { name: 'credential assignment', re: /\b(?:api[_-]?key|secret[_-]?key|password|passwd|access[_-]?token|auth[_-]?token)\s*[:=]\s*["']?(?!<)[A-Za-z0-9/+_.-]{12,}/gi },
29
+ ];
30
+ // @comment -- "Replaces secret-shaped substrings with <redacted:kind> so evidence.json and the report never carry a live token, even locally."
31
+ export function redact(text) {
32
+ let out = text;
33
+ for (const p of SECRET_PATTERNS)
34
+ out = out.replace(p.re, `<redacted:${p.name}>`);
35
+ return out;
36
+ }
37
+ const red = (v, max = 1200) => redact(String(v ?? '')).slice(0, max);
38
+ const key8 = (k) => k.replace(/[^a-f0-9]/gi, '').slice(0, 8);
39
+ // @comment -- "Reads every cxg result under .guardlink/pentest-findings (depth 3, JSON only): a pentest run report (confirmed_findings, mitigation_verifications, not_run, auth_skipped, probe_refusals) or a cxg scan output (findings, executions). Each becomes RUN-n with its findings, executions and not-run causes as items; unreadable files are skipped and named as a gap."
40
+ export function readRuns(root, items, gaps) {
41
+ const base = join(root, '.guardlink', 'pentest-findings');
42
+ if (!existsSync(base))
43
+ return [];
44
+ const files = [];
45
+ const walk = (dir, depth) => {
46
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
47
+ const p = join(dir, e.name);
48
+ if (e.isDirectory() && depth < 3)
49
+ walk(p, depth + 1);
50
+ else if (e.isFile() && e.name.endsWith('.json'))
51
+ files.push(p);
52
+ }
53
+ };
54
+ walk(base, 0);
55
+ const runs = [];
56
+ let n = 0;
57
+ for (const file of files.sort()) {
58
+ let j;
59
+ try {
60
+ j = JSON.parse(readFileSync(file, 'utf8'));
61
+ }
62
+ catch {
63
+ gaps.push(`unreadable run file ${relative(root, file)}`);
64
+ continue;
65
+ }
66
+ const pentest = j && (Array.isArray(j.confirmed_findings) || Array.isArray(j.mitigation_verifications));
67
+ const scan = j && typeof j.scan_id === 'string' && Array.isArray(j.findings) && Array.isArray(j.executions);
68
+ if (!pentest && !scan)
69
+ continue;
70
+ n += 1;
71
+ const id = `RUN-${n}`;
72
+ const rel = relative(root, file);
73
+ const findings = pentest ? arr(j.confirmed_findings) : arr(j.findings);
74
+ findings.forEach((f, k) => {
75
+ items.push({
76
+ id: `${id}/F-${k + 1}`,
77
+ kind: 'run-finding',
78
+ supports: ['reproduced'],
79
+ summary: red(f.title ?? f.description ?? f.vuln_class ?? 'finding', 300),
80
+ data: {
81
+ template: f.template_id ?? null,
82
+ severity: f.severity ?? null,
83
+ endpoint: f.endpoint ?? f.target ?? null,
84
+ cwe: arr(f.cwe_ids),
85
+ claimKey: f.claim_key ?? null,
86
+ threat: f.threat ?? f.threat_id ?? null,
87
+ request: red(f.evidence?.request ?? f.evidence?.observed?.request ?? f.payload ?? ''),
88
+ response: red(f.evidence?.response ?? f.evidence?.observed ?? ''),
89
+ proofStatus: f.proof_status ?? null,
90
+ triage: f.triage ?? null,
91
+ },
92
+ });
93
+ });
94
+ const executions = pentest ? arr(j.probe_executions) : arr(j.executions);
95
+ executions.forEach((x, k) => {
96
+ const status = String(x.status ?? '?');
97
+ items.push({
98
+ id: `${id}/X-${k + 1}`,
99
+ kind: 'run-execution',
100
+ supports: status === 'confirmed' ? ['reproduced'] : status === 'refuted' ? ['held'] : [],
101
+ summary: `${x.template_id ?? x.template ?? '?'}: ${status}`,
102
+ data: { template: x.template_id ?? x.template ?? null, status, detail: x.detail ?? x.reason ?? null },
103
+ });
104
+ });
105
+ const held = pentest ? arr(j.mitigation_verifications) : [];
106
+ held.forEach((h, k) => items.push({ id: `${id}/H-${k + 1}`, kind: 'run-execution', supports: ['held'], summary: red(h.title ?? h.template_id ?? 'mitigation held', 300), data: { template: h.template_id ?? null } }));
107
+ const notRun = pentest ? [...arr(j.not_run), ...arr(j.auth_skipped), ...arr(j.probe_refusals)] : [];
108
+ notRun.forEach((x, k) => items.push({ id: `${id}/N-${k + 1}`, kind: 'run-not-run', supports: [], summary: `${x.template ?? '?'} not run: ${x.cause ?? x.reason_kind ?? x.status ?? 'unknown'} — ${red(x.reason ?? '', 200)}` }));
109
+ const summary = {
110
+ id,
111
+ path: rel,
112
+ kind: pentest ? 'pentest' : 'scan',
113
+ scanId: j.scan_id ?? null,
114
+ at: j.completed_at ?? j.started_at ?? null,
115
+ target: j.target ?? findings[0]?.target ?? null,
116
+ authProfiles: arr(j.auth_profiles),
117
+ confirmed: findings.length + executions.filter((x) => x.status === 'confirmed' && !findings.length).length,
118
+ held: held.length + executions.filter((x) => x.status === 'refuted').length,
119
+ notRun: notRun.length,
120
+ caveats: arr(j.caveats).map((c) => red(typeof c === 'string' ? c : JSON.stringify(c), 300)),
121
+ };
122
+ items.push({ id, kind: 'run', supports: [], summary: `${summary.kind} ${summary.scanId ?? ''} at ${summary.at ?? '?'} against ${summary.target ?? '?'}: ${summary.confirmed} confirmed, ${summary.held} held, ${summary.notRun} not run`, file: rel });
123
+ runs.push(summary);
124
+ }
125
+ return runs;
126
+ }
127
+ // @comment -- "The level an exposure's evidence supports: a ledger confirmation is reproduced, a refutation is a held control, a reachable SAST finding joined to its file is code review, anything else open is suspected."
128
+ export function levelFor(state, sastCorroborated) {
129
+ if (state === 'confirmed' || state === 'retest')
130
+ return 'reproduced';
131
+ if (state === 'refuted')
132
+ return 'held';
133
+ return sastCorroborated ? 'code-review' : 'suspected';
134
+ }
135
+ const slugOf = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
136
+ // @comment -- "Builds the evidence bundle for root: runs guardlink parse, hypothesis list and boundaries, the tool versions and git concurrently (argv, no shell, bounded), reads runs, SAST, intent, scope and proving, optionally asks the code graph, and links each exposure to its ledger record, controls and SAST. Pure apart from those read-only calls; writes nothing."
137
+ export async function gatherEvidence(root, env, opts) {
138
+ const run = await runner();
139
+ const now = opts.now ?? new Date();
140
+ const o = { cwd: root, env, timeoutMs: 60000 };
141
+ const quick = { cwd: root, env, timeoutMs: 10000 };
142
+ const gaps = [];
143
+ const [parseR, ledgerR, boundR, glV, cxgV, brV, graph] = await Promise.all([
144
+ run('guardlink', ['parse', '.'], o),
145
+ run('guardlink', ['hypothesis', 'list', '--json', '.'], o),
146
+ run('guardlink', ['hypothesis', 'boundaries', '--json', '.'], o),
147
+ run('guardlink', ['--version'], quick),
148
+ run('cxg', ['--version'], quick),
149
+ (() => { const b = graphBin('codegraph-build', env); return b ? run(b, ['--version'], quick) : Promise.resolve(null); })(),
150
+ opts.graph === false ? Promise.resolve(null) : graphDetail(root, env),
151
+ ]);
152
+ const git = (args) => {
153
+ const r = spawnSync('git', args, { cwd: root, env, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
154
+ return r.status === 0 ? r.stdout.trim() : null;
155
+ };
156
+ const model = parseJson(parseR);
157
+ if (!model)
158
+ gaps.push(parseR.missing ? 'guardlink is not installed: no threat model' : 'guardlink parse failed: no threat model');
159
+ const ledgerJ = parseJson(ledgerR);
160
+ const boundJ = parseJson(boundR);
161
+ if (!ledgerJ)
162
+ gaps.push('guardlink hypothesis list failed: tested states unknown');
163
+ const items = [];
164
+ const runs = readRuns(root, items, gaps);
165
+ if (!runs.length)
166
+ gaps.push('no cxg run results under .guardlink/pentest-findings: nothing was exercised at runtime');
167
+ // SAST: the candidates' reachable findings corroborate an exposure in the same file.
168
+ const sastLib = await lib('sast.mjs');
169
+ const sastSummary = sastLib.readSast(root, env);
170
+ const cands = sastLib.sastCandidates(root, { all: false });
171
+ if (!sastSummary)
172
+ gaps.push('no SAST run in .bravos/sast');
173
+ const sastFiles = new Set();
174
+ let s = 0;
175
+ for (const [group, list] of [['joined', cands?.joined], ['unjoined', cands?.unjoined], ['dependency', cands?.dependencies], ['secret', cands?.secrets]]) {
176
+ for (const f of arr(list)) {
177
+ s += 1;
178
+ const reached = group !== 'dependency' || f.vulnerableFunctionReached === true;
179
+ if (group !== 'dependency' && f.file)
180
+ sastFiles.add(String(f.file));
181
+ items.push({
182
+ id: `SAST-${s}`,
183
+ kind: 'sast',
184
+ supports: reached ? ['code-review'] : [],
185
+ summary: `${group}: ${f.rule ?? '?'} ${f.severity ?? ''} ${f.file ?? '?'}:${f.line ?? '?'}${f.route ? ` (${f.route})` : ''}${group === 'dependency' ? ` vulnerable function reached: ${String(f.vulnerableFunctionReached ?? 'unknown')}` : ''}`,
186
+ file: f.file,
187
+ line: num(f.line),
188
+ });
189
+ }
190
+ }
191
+ // Ledger records by file:line:threat, to join each exposure to its tested state.
192
+ const ledgerBy = new Map();
193
+ for (const r of arr(ledgerJ?.records)) {
194
+ ledgerBy.set(`${r.file}:${r.line}:${strip(r.threat)}`, r);
195
+ if (r.outcome) {
196
+ const outcome = String(r.outcome.outcome ?? r.state);
197
+ items.push({
198
+ id: `LED-${key8(String(r.key))}`,
199
+ kind: 'ledger',
200
+ supports: outcome === 'confirmed' ? ['reproduced'] : outcome === 'refuted' ? ['held'] : [],
201
+ summary: `${r.claim ?? `${r.asset} to ${r.threat}`}: ${outcome} by ${r.outcome.by ?? '?'} at ${r.outcome.at ?? '?'}${r.state === 'retest' ? ' (code changed since: retest due)' : ''}`,
202
+ file: r.file,
203
+ line: num(r.line),
204
+ data: { evidence: red(r.outcome.evidence ?? '', 2000), source: r.outcome.source ?? null, state: r.state, previous: r.previous ?? null },
205
+ });
206
+ }
207
+ }
208
+ const mitigations = arr(model?.mitigations);
209
+ mitigations.forEach((m, k) => items.push({ id: `CTL-${k + 1}`, kind: 'control', supports: [], summary: `${m.control} mitigates ${m.threat} on ${m.asset}: ${red(m.description, 300)}`, file: m.location?.file, line: num(m.location?.line) }));
210
+ arr(boundJ?.records).forEach((b, k) => {
211
+ const st = String(b.state ?? 'unverified');
212
+ items.push({ id: `BND-${k + 1}`, kind: 'boundary', supports: st === 'supported' ? ['held'] : st === 'contradicted' ? ['reproduced'] : [], summary: `${b.claim ?? b.id}: ${st}`, file: b.file, line: num(b.line) });
213
+ });
214
+ arr(model?.acceptances).forEach((a, k) => items.push({ id: `ACC-${k + 1}`, kind: 'acceptance', supports: [], summary: `${a.threat} on ${a.asset} accepted by ${a.accepted_by ?? '?'}${a.expires ? ` until ${a.expires}` : ''}: ${red(a.description, 300)}`, file: a.location?.file, line: num(a.location?.line) }));
215
+ const candidates = [];
216
+ arr(model?.exposures).forEach((e, k) => {
217
+ const file = String(e.location?.file ?? '?');
218
+ const line = num(e.location?.line);
219
+ const threat = strip(e.threat);
220
+ const asset = strip(e.asset);
221
+ const rec = ledgerBy.get(`${file}:${line}:${threat}`);
222
+ const state = String(rec?.state ?? e.hypothesis?.state ?? 'untested');
223
+ const level = levelFor(state, sastFiles.has(file));
224
+ const id = `EXP-${k + 1}`;
225
+ const ctl = mitigations
226
+ .map((m, j) => (strip(m.threat) === threat && strip(m.asset) === asset ? `CTL-${j + 1}` : null))
227
+ .filter((x) => !!x);
228
+ const evidence = [id];
229
+ if (rec?.outcome)
230
+ evidence.push(`LED-${key8(String(rec.key))}`);
231
+ for (const it of items)
232
+ if (it.kind === 'sast' && it.file === file && it.supports.length)
233
+ evidence.push(it.id);
234
+ // The exposure's level comes from its tested state in the ledger (or SAST), so citing the exposure supports that level.
235
+ items.push({ id, kind: 'exposure', supports: level === 'suspected' ? ['suspected'] : [level, 'suspected'], summary: `${asset} exposed to ${threat} [${e.severity ?? 'unset'}]: ${red(e.description, 400)}`, file, line });
236
+ candidates.push({
237
+ id,
238
+ threat,
239
+ asset,
240
+ severity: String(e.severity ?? 'unset'),
241
+ cwe: arr(e.external_refs).filter((r) => /^cwe:/i.test(r)).map((r) => r.replace(/^cwe:/i, '')),
242
+ file,
243
+ line,
244
+ symbol: e.location?.parent_symbol ?? null,
245
+ description: red(e.description, 600),
246
+ claimKey: rec?.key ?? null,
247
+ state,
248
+ level,
249
+ retestDue: state === 'retest',
250
+ mitigatedBy: ctl,
251
+ evidence,
252
+ previous: rec?.previous ? { outcome: String(rec.previous.outcome), by: String(rec.previous.by ?? '?'), at: String(rec.previous.at ?? '?') } : null,
253
+ });
254
+ });
255
+ let narrowed = candidates;
256
+ if (opts.kind === 'finding' && opts.finding) {
257
+ const f = opts.finding;
258
+ narrowed = candidates.filter((c) => (c.claimKey && c.claimKey.startsWith(f)) || `${c.file}:${c.line}` === f || c.id === f);
259
+ if (!narrowed.length)
260
+ throw new BravosError(`no exposure matches ${f}; give a claim key prefix, file:line or EXP id from bravos report data --json`);
261
+ }
262
+ if (opts.kind === 'retest')
263
+ narrowed = candidates.filter((c) => c.previous || c.state === 'retest' || c.state === 'refuted' || c.state === 'confirmed');
264
+ const intentFile = join(root, '.guardlink', 'intent.md');
265
+ const intentText = existsSync(intentFile) ? readFileSync(intentFile, 'utf8') : null;
266
+ const briefing = await lib('briefing.mjs');
267
+ const intent = briefing.readIntent(root);
268
+ if (!intentText)
269
+ gaps.push('no .guardlink/intent.md: severities are not calibrated to the product');
270
+ const scopeFile = ['.guardlink/scope.yaml', '.guardlink/scope.yml'].find((p) => existsSync(join(root, p)));
271
+ const autopilot = await lib('autopilot.mjs');
272
+ const proving = autopilot.readProving(root);
273
+ const sha = git(['rev-parse', 'HEAD']);
274
+ const porcelain = git(['status', '--porcelain']);
275
+ const kindSlug = opts.kind === 'finding' && opts.finding ? `-${slugOf(narrowed[0]?.threat ?? opts.finding)}` : '';
276
+ const stamp = now.toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 13);
277
+ const firstLine = (r) => (r.status === 0 ? r.stdout.trim().split('\n')[0] : null);
278
+ if (graph === null && opts.graph !== false)
279
+ gaps.push('code graph unavailable: reach not established from entry points');
280
+ const s0 = model?.coverage;
281
+ return {
282
+ schema: EVIDENCE_SCHEMA,
283
+ id: opts.id ?? `${stamp}-${opts.kind}${kindSlug}`,
284
+ kind: opts.kind,
285
+ finding: opts.finding ?? null,
286
+ generatedAt: now.toISOString(),
287
+ repo: basename(root),
288
+ git: { sha, short: sha ? sha.slice(0, 9) : null, branch: git(['rev-parse', '--abbrev-ref', 'HEAD']), dirtyFiles: porcelain === null ? null : porcelain.split('\n').filter(Boolean).length, base: git(['symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD']) },
289
+ tools: { bravos: version(), guardlink: firstLine(glV), cxg: firstLine(cxgV), codegraph: brV ? firstLine(brV) : null },
290
+ intent: { status: intent.status, text: intentText },
291
+ scope: scopeFile ? { path: scopeFile, text: redact(readFileSync(join(root, scopeFile), 'utf8')).slice(0, 6000) } : null,
292
+ proving,
293
+ model: model
294
+ ? {
295
+ assets: arr(model.assets).length,
296
+ threats: arr(model.threats).length,
297
+ controls: arr(model.controls).length,
298
+ actors: arr(model.actors).length,
299
+ flows: arr(model.flows).length,
300
+ boundaries: arr(model.boundaries).length,
301
+ mitigations: mitigations.length,
302
+ exposures: arr(model.exposures).length,
303
+ confirmed: arr(model.confirmed).length,
304
+ acceptances: arr(model.acceptances).length,
305
+ annotations: num(model.annotations_parsed) ?? 0,
306
+ }
307
+ : null,
308
+ coverage: model
309
+ ? { annotatedFiles: arr(model.annotated_files).length, sourceFiles: arr(model.annotated_files).length + arr(model.unannotated_files).length, percent: num(s0?.coverage_percent), annotations: num(s0?.annotation_count) }
310
+ : null,
311
+ ledger: ledgerJ?.summary ? { untested: Number(ledgerJ.summary.untested) || 0, confirmed: Number(ledgerJ.summary.confirmed) || 0, refuted: Number(ledgerJ.summary.refuted) || 0, retest: Number(ledgerJ.summary.retest) || 0 } : null,
312
+ boundaryLedger: boundJ?.summary ? { unverified: Number(boundJ.summary.unverified) || 0, supported: Number(boundJ.summary.supported) || 0, contradicted: Number(boundJ.summary.contradicted) || 0, retest: Number(boundJ.summary.retest) || 0 } : null,
313
+ candidates: narrowed,
314
+ items,
315
+ runs,
316
+ sast: sastSummary,
317
+ graph: graph && 'notBuilt' in graph ? 'not-built' : graph,
318
+ gaps,
319
+ };
320
+ }
321
+ // @comment -- "Writes the bundle as .bravos/reports/<id>/evidence.json and a fresh meta.json, creating .bravos/reports/.gitignore (*) first so reports, which quote request and response evidence, are never committed by accident."
322
+ export function writeEvidence(root, bundle) {
323
+ const base = join(root, REPORTS_DIR);
324
+ mkdirSync(base, { recursive: true });
325
+ const ignore = join(base, '.gitignore');
326
+ if (!existsSync(ignore))
327
+ writeFileSync(ignore, '*\n');
328
+ const dir = reportDir(root, bundle.id);
329
+ mkdirSync(dir, { recursive: true });
330
+ const evidence = join(dir, 'evidence.json');
331
+ writeFileSync(evidence, JSON.stringify(bundle, null, 2) + '\n');
332
+ const metaFile = join(dir, 'meta.json');
333
+ if (!existsSync(metaFile))
334
+ writeMeta(root, { schema: META_SCHEMA, id: bundle.id, kind: bundle.kind, createdAt: bundle.generatedAt, sha: bundle.git.sha, title: null, check: null, exports: {} });
335
+ return { dir, evidence };
336
+ }
337
+ export function readEvidence(root, id) {
338
+ const file = join(reportDir(root, id), 'evidence.json');
339
+ if (!existsSync(file))
340
+ throw new BravosError(`no evidence for report ${id}: run bravos report data first`);
341
+ const b = JSON.parse(readFileSync(file, 'utf8'));
342
+ if (b?.schema !== EVIDENCE_SCHEMA)
343
+ throw new BravosError(`${relative(root, file)} is not a ${EVIDENCE_SCHEMA} bundle`);
344
+ return b;
345
+ }
346
+ // @comment -- "Prefixes every id a member's bundle carries (items, candidates and their evidence, runs) with the member name, so ids from different services cannot collide in one report: orders:EXP-3, gateway:RUN-1/F-1."
347
+ export function prefixBundle(member, b) {
348
+ const p = (id) => `${member}:${id}`;
349
+ return {
350
+ ...b,
351
+ candidates: b.candidates.map((c) => ({ ...c, id: p(c.id), evidence: c.evidence.map(p), mitigatedBy: c.mitigatedBy.map(p), file: `${member}/${c.file}` })),
352
+ items: b.items.map((i) => ({ ...i, id: p(i.id), ...(i.file ? { file: `${member}/${i.file}` } : {}) })),
353
+ runs: b.runs.map((r) => ({ ...r, id: p(r.id), path: `${member}/${r.path}` })),
354
+ };
355
+ }
356
+ // @comment -- "The evidence bundle for a whole bravos workspace: gatherEvidence in every member concurrently, each prefixed with its member name, merged into one bundle with the services' own git, coverage and ledger kept per member, plus EDGE-n items for the cross-service edges and UNR-n items for unresolved references from the cached bravos export. Read-only towards the members."
357
+ export async function gatherWorkspaceEvidence(root, env, ws, opts) {
358
+ const now = opts.now ?? new Date();
359
+ const per = await Promise.all(ws.members.map((m) => gatherEvidence(m.path, env, { ...opts, now, kind: opts.kind === 'finding' ? 'assessment' : opts.kind }).then((b) => ({ m, b: prefixBundle(m.name, b) }))));
360
+ const items = per.flatMap(({ b }) => b.items);
361
+ const edges = (ws.edges ?? []).map((e, k) => ({ id: `EDGE-${k + 1}`, ...e }));
362
+ for (const e of edges)
363
+ items.push({ id: e.id, kind: 'boundary', supports: [], summary: `${e.srcMember}:${e.src} calls ${e.dstMember}:${e.dst}${e.mechanism ? ` via ${e.mechanism}` : ''} (declared in ${e.declaredIn})` });
364
+ const unresolved = ws.unresolved.map((u, k) => ({ id: `UNR-${k + 1}`, ...u }));
365
+ for (const u of unresolved)
366
+ items.push({ id: u.id, kind: 'boundary', supports: [], summary: `${u.member}: ${u.reference} names nothing any service defines` });
367
+ const gaps = per.flatMap(({ m, b }) => b.gaps.map((g) => `${m.name}: ${g}`));
368
+ if (!ws.edges)
369
+ gaps.push('cross-service edges unknown: run bravos workspace show');
370
+ const stamp = now.toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 13);
371
+ const first = per[0]?.b;
372
+ return {
373
+ schema: EVIDENCE_SCHEMA,
374
+ id: opts.id ?? `${stamp}-${opts.kind}-${ws.name}`,
375
+ kind: opts.kind,
376
+ finding: null,
377
+ generatedAt: now.toISOString(),
378
+ repo: ws.name,
379
+ git: { sha: null, short: null, branch: null, dirtyFiles: null, base: null },
380
+ tools: first?.tools ?? { bravos: version(), guardlink: null, cxg: null, codegraph: null },
381
+ intent: { status: per.map(({ m, b }) => `${m.name}: ${b.intent.status}`).join(', '), text: per.map(({ m, b }) => (b.intent.text ? `## ${m.name}\n\n${b.intent.text}` : '')).filter(Boolean).join('\n\n') || null },
382
+ scope: null,
383
+ proving: null,
384
+ model: null,
385
+ coverage: null,
386
+ ledger: null,
387
+ boundaryLedger: null,
388
+ candidates: per.flatMap(({ b }) => b.candidates),
389
+ items,
390
+ runs: per.flatMap(({ b }) => b.runs),
391
+ sast: null,
392
+ graph: null,
393
+ gaps,
394
+ workspace: {
395
+ name: ws.name,
396
+ members: per.map(({ m, b }) => ({ name: m.name, path: m.path, url: m.url, git: b.git, coverage: b.coverage, ledger: b.ledger, model: b.model, runs: b.runs.length, gaps: b.gaps })),
397
+ edges,
398
+ unresolved,
399
+ },
400
+ };
401
+ }