heron-ai 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/bin/heron.js +31 -2
  2. package/dist/bin/heron.js.map +1 -1
  3. package/dist/src/analysis/analyzer.d.ts +1 -1
  4. package/dist/src/analysis/analyzer.d.ts.map +1 -1
  5. package/dist/src/analysis/analyzer.js +120 -6
  6. package/dist/src/analysis/analyzer.js.map +1 -1
  7. package/dist/src/analysis/risk-scorer.d.ts +32 -0
  8. package/dist/src/analysis/risk-scorer.d.ts.map +1 -1
  9. package/dist/src/analysis/risk-scorer.js +134 -0
  10. package/dist/src/analysis/risk-scorer.js.map +1 -1
  11. package/dist/src/commands/diff.d.ts +17 -0
  12. package/dist/src/commands/diff.d.ts.map +1 -0
  13. package/dist/src/commands/diff.js +63 -0
  14. package/dist/src/commands/diff.js.map +1 -0
  15. package/dist/src/compliance/control-mappings.d.ts +21 -0
  16. package/dist/src/compliance/control-mappings.d.ts.map +1 -0
  17. package/dist/src/compliance/control-mappings.js +182 -0
  18. package/dist/src/compliance/control-mappings.js.map +1 -0
  19. package/dist/src/compliance/frameworks.d.ts +24 -0
  20. package/dist/src/compliance/frameworks.d.ts.map +1 -0
  21. package/dist/src/compliance/frameworks.js +55 -0
  22. package/dist/src/compliance/frameworks.js.map +1 -0
  23. package/dist/src/compliance/index.d.ts +9 -0
  24. package/dist/src/compliance/index.d.ts.map +1 -0
  25. package/dist/src/compliance/index.js +8 -0
  26. package/dist/src/compliance/index.js.map +1 -0
  27. package/dist/src/compliance/mapper.d.ts +126 -0
  28. package/dist/src/compliance/mapper.d.ts.map +1 -0
  29. package/dist/src/compliance/mapper.js +443 -0
  30. package/dist/src/compliance/mapper.js.map +1 -0
  31. package/dist/src/compliance/types.d.ts +120 -0
  32. package/dist/src/compliance/types.d.ts.map +1 -0
  33. package/dist/src/compliance/types.js +99 -0
  34. package/dist/src/compliance/types.js.map +1 -0
  35. package/dist/src/diff/differ.d.ts +9 -0
  36. package/dist/src/diff/differ.d.ts.map +1 -0
  37. package/dist/src/diff/differ.js +52 -0
  38. package/dist/src/diff/differ.js.map +1 -0
  39. package/dist/src/interview/interviewer.d.ts +2 -0
  40. package/dist/src/interview/interviewer.d.ts.map +1 -1
  41. package/dist/src/interview/interviewer.js.map +1 -1
  42. package/dist/src/interview/protocol.d.ts.map +1 -1
  43. package/dist/src/interview/protocol.js +28 -5
  44. package/dist/src/interview/protocol.js.map +1 -1
  45. package/dist/src/interview/questions.d.ts.map +1 -1
  46. package/dist/src/interview/questions.js +55 -0
  47. package/dist/src/interview/questions.js.map +1 -1
  48. package/dist/src/llm/client.d.ts +26 -1
  49. package/dist/src/llm/client.d.ts.map +1 -1
  50. package/dist/src/llm/client.js +108 -15
  51. package/dist/src/llm/client.js.map +1 -1
  52. package/dist/src/llm/prompts.d.ts +27 -1
  53. package/dist/src/llm/prompts.d.ts.map +1 -1
  54. package/dist/src/llm/prompts.js +133 -1
  55. package/dist/src/llm/prompts.js.map +1 -1
  56. package/dist/src/report/generator.d.ts +1 -7
  57. package/dist/src/report/generator.d.ts.map +1 -1
  58. package/dist/src/report/generator.js +47 -236
  59. package/dist/src/report/generator.js.map +1 -1
  60. package/dist/src/report/templates.d.ts +2 -1
  61. package/dist/src/report/templates.d.ts.map +1 -1
  62. package/dist/src/report/templates.js +436 -84
  63. package/dist/src/report/templates.js.map +1 -1
  64. package/dist/src/report/types.d.ts +34 -19
  65. package/dist/src/report/types.d.ts.map +1 -1
  66. package/dist/src/report/types.js +8 -4
  67. package/dist/src/report/types.js.map +1 -1
  68. package/dist/src/server/index.d.ts +1 -1
  69. package/dist/src/server/index.d.ts.map +1 -1
  70. package/dist/src/server/index.js +212 -55
  71. package/dist/src/server/index.js.map +1 -1
  72. package/dist/src/server/sessions.d.ts +10 -0
  73. package/dist/src/server/sessions.d.ts.map +1 -1
  74. package/dist/src/server/sessions.js +73 -9
  75. package/dist/src/server/sessions.js.map +1 -1
  76. package/dist/src/util/provided.d.ts +49 -0
  77. package/dist/src/util/provided.d.ts.map +1 -0
  78. package/dist/src/util/provided.js +83 -0
  79. package/dist/src/util/provided.js.map +1 -0
  80. package/dist/src/util/systems.d.ts +15 -0
  81. package/dist/src/util/systems.d.ts.map +1 -0
  82. package/dist/src/util/systems.js +41 -0
  83. package/dist/src/util/systems.js.map +1 -0
  84. package/package.json +1 -1
  85. package/skills/heron-audit/bin/heron-update-check +13 -4
@@ -1,30 +1,52 @@
1
- /** Filter out interview/orchestration platforms that aren't real business systems */
2
- function isBusinessSystem(s) {
3
- const id = s.systemId.toLowerCase();
4
- if (/\bheron\b/.test(id))
5
- return false;
6
- if (/internal\s*(orchestrat|api|platform)/.test(id))
7
- return false;
8
- if (/interview\s*(platform|endpoint|api)/.test(id))
9
- return false;
10
- if (/audit\s*(platform|endpoint|api)/.test(id))
11
- return false;
12
- // Platform session token with no real scopes = orchestration layer
13
- if (/platform.?session.?token/i.test(id) && s.scopesRequested.length === 0)
14
- return false;
15
- return true;
1
+ import { isProvided, UNKNOWN_PLACEHOLDER } from '../util/provided.js';
2
+ import { isBusinessSystem } from '../util/systems.js';
3
+ // ─── AAP-43 P1 #5: overall regulatory status ──────────────────────────────
4
+ /**
5
+ * Reduce all activated framework flags into a single status label + gap
6
+ * counter. Replaces the prior EU/US/UK jurisdiction matrix which couldn't
7
+ * vary without US/UK frameworks in the OSS registry.
8
+ *
9
+ * Labels (descending severity):
10
+ * - "Action Required" — at least one action-required flag
11
+ * - "Needs Clarification" — at least one clarification-needed flag
12
+ * - "Review" — at least one warning-level flag
13
+ * - "Not Triggered" — no activated framework flags
14
+ */
15
+ function summarizeOverallStatus(c) {
16
+ const all = (c.all ?? []);
17
+ if (all.length === 0)
18
+ return 'Not Triggered';
19
+ let label;
20
+ if (all.some(f => f.severity === 'action-required'))
21
+ label = 'Action Required';
22
+ else if (all.some(f => f.severity === 'clarification-needed'))
23
+ label = 'Needs Clarification';
24
+ else if (all.some(f => f.severity === 'warning'))
25
+ label = 'Review';
26
+ else
27
+ label = 'Not Triggered';
28
+ const mandatoryGaps = all.filter(f => f.tier === 'mandatory' && f.severity !== 'info').length;
29
+ const voluntaryGaps = all.filter(f => f.tier === 'voluntary' && f.severity !== 'info').length;
30
+ const parts = [];
31
+ if (mandatoryGaps > 0)
32
+ parts.push(`${mandatoryGaps} mandatory-framework gap${mandatoryGaps === 1 ? '' : 's'}`);
33
+ if (voluntaryGaps > 0)
34
+ parts.push(`${voluntaryGaps} voluntary-framework gap${voluntaryGaps === 1 ? '' : 's'}`);
35
+ const suffix = parts.length > 0 ? ` (${parts.join(', ')})` : '';
36
+ return `${label}${suffix}`;
16
37
  }
38
+ // isBusinessSystem lives in src/util/systems.ts (shared with analyzer + mapper).
17
39
  export function renderMarkdownReport(report) {
18
40
  const sections = [
19
41
  renderHeader(report),
20
42
  renderScopeAndMethodology(report),
21
43
  renderSummary(report),
22
44
  renderAgentProfile(report),
23
- renderFindings(report.risks),
45
+ renderFindings(report.risks, report.compliance),
24
46
  renderSystems(report.systems),
25
47
  renderPositiveFindings(report),
26
48
  renderVerdict(report),
27
- report.regulatoryCompliance ? renderRegulatoryCompliance(report.regulatoryCompliance) : null,
49
+ report.compliance ? renderRegulatoryCompliance(report.compliance, report) : null,
28
50
  report.dataQuality ? renderDataQuality(report.dataQuality) : null,
29
51
  renderTranscript(report.transcript),
30
52
  renderDisclaimer(),
@@ -33,30 +55,25 @@ export function renderMarkdownReport(report) {
33
55
  }
34
56
  // ─── Header ──────────────────────────────────────────────────────────────────
35
57
  function renderHeader(report) {
36
- const riskIcon = report.overallRiskLevel === 'critical' || report.overallRiskLevel === 'high' ? '!!' : '';
58
+ // Reviewer feedback (2026-04-25): the prior `!!` exclamation marker on
59
+ // HIGH/CRITICAL headers ("Risk Level: HIGH !!") was called out as
60
+ // "not a serious-document tone" — CISOs do not want excitement in audit
61
+ // headers. The `**Risk Level**: HIGH` label itself is already strong;
62
+ // the riskIcon adds nothing and undercuts credibility. Dropped.
37
63
  const dqPart = report.dataQuality ? ` | **Data Quality**: ${report.dataQuality.score}/100` : '';
38
- // Regulatory one-liner
64
+ // AAP-43 P1 #5: single overall regulatory status label (replaces
65
+ // EU/US/UK matrix). The matrix implied we'd analyzed each jurisdiction,
66
+ // but we don't know the deployer's jurisdiction and only EU-mandatory
67
+ // frameworks are in OSS scope (see AAP-42). A single label + gap counter
68
+ // is honest: "here is the highest unresolved severity across activated
69
+ // frameworks, and how many mandatory vs voluntary gaps there are."
39
70
  let regLine = '';
40
- if (report.regulatoryCompliance) {
41
- const rc = report.regulatoryCompliance;
42
- const regParts = [];
43
- const summarizeJurisdiction = (flags) => {
44
- if (flags.some(f => f.severity === 'action-required'))
45
- return 'Action Required';
46
- if (flags.some(f => f.severity === 'clarification-needed'))
47
- return 'Needs Clarification';
48
- if (flags.some(f => f.severity === 'warning'))
49
- return 'Review';
50
- return 'Clear';
51
- };
52
- regParts.push(`EU: ${summarizeJurisdiction(rc.eu)}`);
53
- regParts.push(`US: ${summarizeJurisdiction(rc.us)}`);
54
- regParts.push(`UK: ${summarizeJurisdiction(rc.uk)}`);
55
- regLine = `\n**Regulatory**: ${regParts.join(' | ')}`;
71
+ if (report.compliance) {
72
+ regLine = `\n**Regulatory Status**: ${summarizeOverallStatus(report.compliance)}`;
56
73
  }
57
74
  return `# Agent Access Audit Report
58
75
 
59
- **Generated**: ${report.metadata.date} | **Agent**: ${report.metadata.target} | **Risk Level**: ${report.overallRiskLevel.toUpperCase()} ${riskIcon}${dqPart}${regLine}`;
76
+ **Generated**: ${report.metadata.date} | **Agent**: ${report.metadata.target} | **Risk Level**: ${report.overallRiskLevel.toUpperCase()}${dqPart}${regLine}`;
60
77
  }
61
78
  // ─── Scope & Methodology ────────────────────────────────────────────────────
62
79
  function renderScopeAndMethodology(report) {
@@ -120,9 +137,17 @@ function renderSummary(report) {
120
137
  const dashboard = `| Risk | Systems | Findings |
121
138
  |------|---------|----------|
122
139
  | **${report.overallRiskLevel.toUpperCase()}** | ${systemCount} | ${findingsParts.join(', ')} |`;
140
+ let methodology = '';
141
+ if (report.compliance) {
142
+ const c = report.compliance;
143
+ const activated = (c.frameworksActivated ?? []);
144
+ const names = activated.map(id => frameworkShortName(id)).filter(Boolean);
145
+ const fwList = names.length > 0 ? names.join(', ') : 'see Regulatory Compliance section';
146
+ methodology = `\n\n> **Risk methodology** anchored to ${names.length} frameworks: ${fwList}. Mapping version: \`${c.mappingVersion}\`.`;
147
+ }
123
148
  return `## Executive Summary
124
149
 
125
- ${dashboard}
150
+ ${dashboard}${methodology}
126
151
 
127
152
  ${report.summary}`;
128
153
  }
@@ -131,12 +156,12 @@ function renderAgentProfile(report) {
131
156
  const lines = [`- **Purpose**: ${report.agentPurpose}`];
132
157
  if (report.agentTrigger)
133
158
  lines.push(`- **Trigger**: ${report.agentTrigger}`);
134
- if (report.agentOwner && report.agentOwner !== 'NOT PROVIDED') {
159
+ if (isProvided(report.agentOwner)) {
135
160
  lines.push(`- **Owner**: ${report.agentOwner}`);
136
161
  }
137
162
  // Frequency from first system if available
138
163
  const freq = report.systems[0]?.frequencyAndVolume;
139
- if (freq && freq !== 'NOT PROVIDED')
164
+ if (isProvided(freq))
140
165
  lines.push(`- **Frequency**: ${freq}`);
141
166
  return `## Agent Profile
142
167
 
@@ -179,26 +204,32 @@ function renderSystemCard(sys) {
179
204
  const rows = [];
180
205
  const risk = computeSystemRisk(sys);
181
206
  // Scopes
182
- const scopes = sys.scopesRequested.filter(s => s !== 'NOT PROVIDED');
183
- rows.push(`| **Scopes granted** | ${scopes.length > 0 ? scopes.join(', ') : '*NOT PROVIDED*'} |`);
184
- const needed = sys.scopesNeeded.filter(s => s !== 'NOT PROVIDED');
207
+ const scopes = sys.scopesRequested.filter(isProvided);
208
+ rows.push(`| **Scopes granted** | ${scopes.length > 0 ? scopes.join(', ') : `*${UNKNOWN_PLACEHOLDER}*`} |`);
209
+ const needed = sys.scopesNeeded.filter(isProvided);
185
210
  if (needed.length > 0) {
186
211
  rows.push(`| **Scopes needed** | ${needed.join(', ')} |`);
187
212
  }
188
- const excessive = sys.scopesDelta;
213
+ const excessive = sys.scopesDelta.filter(isProvided);
189
214
  if (excessive.length > 0) {
190
215
  rows.push(`| **Excessive** | ${excessive.join(', ')} |`);
191
216
  }
192
217
  // Data sensitivity
193
- if (sys.dataSensitivity && sys.dataSensitivity !== 'NOT PROVIDED') {
218
+ if (isProvided(sys.dataSensitivity)) {
194
219
  rows.push(`| **Data** | ${sys.dataSensitivity} |`);
195
220
  }
221
+ else {
222
+ rows.push(`| **Data** | *${UNKNOWN_PLACEHOLDER}* |`);
223
+ }
196
224
  // Blast radius
197
225
  rows.push(`| **Blast radius** | ${sys.blastRadius} |`);
198
226
  // Frequency
199
- if (sys.frequencyAndVolume && sys.frequencyAndVolume !== 'NOT PROVIDED') {
227
+ if (isProvided(sys.frequencyAndVolume)) {
200
228
  rows.push(`| **Frequency** | ${sys.frequencyAndVolume} |`);
201
229
  }
230
+ else {
231
+ rows.push(`| **Frequency** | *${UNKNOWN_PLACEHOLDER}* |`);
232
+ }
202
233
  // Write operations — inline in card
203
234
  if (sys.writeOperations.length > 0) {
204
235
  const writesSummary = sys.writeOperations.map(w => {
@@ -214,20 +245,87 @@ function renderSystemCard(sys) {
214
245
  ${rows.join('\n')}`;
215
246
  }
216
247
  // ─── Findings ───────────────────────────────────────────────────────────────
217
- function renderFindings(risks) {
218
- const allRisks = [...risks];
219
- const sorted = allRisks
220
- .sort((a, b) => severityOrder(b.severity) - severityOrder(a.severity));
221
- const rows = sorted.map((r, i) => {
248
+ /**
249
+ * Infer which compliance finding type best matches a risk by keyword matching
250
+ * on the risk's title and description. Returns the top-matching finding type
251
+ * or undefined if no strong match.
252
+ */
253
+ function inferFindingType(risk) {
254
+ const text = `${risk.title} ${risk.description}`.toLowerCase();
255
+ if (/permission|scope|access.?control|excessive|least.?privilege|oauth/i.test(text))
256
+ return 'excessive-access';
257
+ if (/write|irreversible|delete|create|modify|append/i.test(text))
258
+ return 'write-risk';
259
+ if (/pii|personal.?data|sensitive|privacy|data.?protection/i.test(text))
260
+ return 'sensitive-data';
261
+ if (/scope.?creep|purpose.?limit|beyond.*need|unnecessary/i.test(text))
262
+ return 'scope-creep';
263
+ if (/classif|decision|scor|rank|profil|bias|discriminat/i.test(text))
264
+ return 'decisions-about-people';
265
+ if (/regulat|compliance|health|sector/i.test(text))
266
+ return 'regulatory-flags';
267
+ return undefined;
268
+ }
269
+ /**
270
+ * Get framework basis string for a finding type from the compliance flags.
271
+ * Returns top 3 mandatory framework controls, formatted as "GDPR Art. 25, EU AI Act Art. 10".
272
+ */
273
+ function getFrameworkBasis(findingType, compliance) {
274
+ if (!compliance)
275
+ return '—';
276
+ const flags = compliance.all.filter((f) => f.triggeredBy === findingType && f.tier === 'mandatory');
277
+ if (flags.length === 0) {
278
+ // Try voluntary if no mandatory
279
+ const volFlags = compliance.all.filter((f) => f.triggeredBy === findingType);
280
+ if (volFlags.length === 0)
281
+ return '—';
282
+ return volFlags.slice(0, 3).map(f => `${f.frameworkId === 'eu-ai-act' ? 'EU AI Act' : f.framework.split(' — ')[0]}`).join(', ');
283
+ }
284
+ // Show top 3 mandatory, framework name + first control ID
285
+ return flags.slice(0, 3).map(f => {
286
+ const name = f.frameworkId === 'eu-ai-act' ? 'EU AI Act' : f.framework.split(' — ')[0];
287
+ const ctrl = (f.controlIds ?? [])[0] ?? '';
288
+ return ctrl ? `${name} ${ctrl}` : name;
289
+ }).join(', ');
290
+ }
291
+ function renderFindings(risks, compliance) {
292
+ if (risks.length === 0) {
293
+ return `## Findings\n\n_No risks identified._`;
294
+ }
295
+ const sorted = [...risks].sort((a, b) => severityOrder(b.severity) - severityOrder(a.severity));
296
+ const renderRow = (r, i) => {
222
297
  const id = `HERON-${String(i + 1).padStart(3, '0')}`;
298
+ const findingType = inferFindingType(r);
299
+ const basis = findingType ? getFrameworkBasis(findingType, compliance) : '—';
223
300
  const remediation = r.mitigation ?? '—';
224
- return `| ${id} | ${r.severity.toUpperCase()} | ${r.title} | ${r.description} | ${remediation} |`;
225
- }).join('\n');
301
+ return `| ${id} | ${r.severity.toUpperCase()} | ${basis} | ${r.title} | ${r.description} | ${remediation} |`;
302
+ };
303
+ const tableHeader = `| ID | Severity | Framework Basis | Finding | Description | Recommendation |
304
+ |----|----------|-----------------|---------|-------------|----------------|`;
305
+ // AAP-43 P2 #7: Top-N triage. A flat 4+ finding table reads as "everything
306
+ // is equal weight." A senior auditor triages: here's the real issue, and
307
+ // here's the long tail. Split at 3; fold the rest into a collapsed section
308
+ // so readers still have access without being buried.
309
+ if (sorted.length <= 3) {
310
+ const rows = sorted.map(renderRow).join('\n');
311
+ return `## Findings\n\n${tableHeader}\n${rows}`;
312
+ }
313
+ const top = sorted.slice(0, 3).map(renderRow).join('\n');
314
+ const rest = sorted.slice(3).map((r, i) => renderRow(r, i + 3)).join('\n');
226
315
  return `## Findings
227
316
 
228
- | ID | Severity | Finding | Description | Recommendation |
229
- |----|----------|---------|-------------|----------------|
230
- ${rows}`;
317
+ ### Top 3 Findings
318
+
319
+ ${tableHeader}
320
+ ${top}
321
+
322
+ <details>
323
+ <summary><strong>Additional findings (${sorted.length - 3})</strong></summary>
324
+
325
+ ${tableHeader}
326
+ ${rest}
327
+
328
+ </details>`;
231
329
  }
232
330
  // ─── Positive Findings ─────────────────────────────────────────────────────
233
331
  function renderPositiveFindings(report) {
@@ -239,8 +337,23 @@ function renderPositiveFindings(report) {
239
337
  positives.push('All write operations are reversible');
240
338
  }
241
339
  // No excessive scopes
340
+ // Reviewer feedback (2026-04-25): a single report had both
341
+ // "No excessive permissions detected" AND a HIGH "Broad Google OAuth
342
+ // write scope exceeds stated single-sheet/single-folder need" finding —
343
+ // a direct internal contradiction. Root cause: the LLM put the broad-
344
+ // scope finding into `risks` (with a HIGH severity) but did not populate
345
+ // `scopesDelta`, so the structural counter said zero excessive scopes.
346
+ // Gate the positive on BOTH: zero scopesDelta entries AND no high-
347
+ // severity risk that the finding-type inferrer classifies as access /
348
+ // excessive-permissions / scope-creep.
242
349
  const totalExcessive = systems.reduce((n, s) => n + s.scopesDelta.length, 0);
243
- if (totalExcessive === 0 && systems.length > 0) {
350
+ const hasAccessRisk = report.risks.some((r) => {
351
+ if (r.severity !== 'high' && r.severity !== 'critical')
352
+ return false;
353
+ const t = inferFindingType(r);
354
+ return t === 'excessive-access' || t === 'scope-creep';
355
+ });
356
+ if (totalExcessive === 0 && systems.length > 0 && !hasAccessRisk) {
244
357
  positives.push('No excessive permissions detected — follows least-privilege principle');
245
358
  }
246
359
  // Limited blast radius
@@ -290,14 +403,14 @@ function renderVerdict(report) {
290
403
  if (!isBusinessSystem(sys))
291
404
  continue;
292
405
  for (const scope of sys.scopesDelta) {
293
- if (scope !== 'NOT PROVIDED') {
406
+ if (isProvided(scope)) {
294
407
  if (!excessiveBySystem.has(sys.systemId))
295
408
  excessiveBySystem.set(sys.systemId, []);
296
409
  excessiveBySystem.get(sys.systemId).push(scope);
297
410
  }
298
411
  }
299
412
  for (const scope of sys.scopesNeeded) {
300
- if (!sys.scopesRequested.includes(scope) && scope !== 'NOT PROVIDED') {
413
+ if (!sys.scopesRequested.includes(scope) && isProvided(scope)) {
301
414
  if (!missingBySystem.has(sys.systemId))
302
415
  missingBySystem.set(sys.systemId, []);
303
416
  missingBySystem.get(sys.systemId).push(scope);
@@ -337,36 +450,275 @@ ${items}
337
450
 
338
451
  </details>`;
339
452
  }
340
- // ─── Regulatory Compliance ──────────────────────────────────────────────────
341
- function renderRegulatoryCompliance(compliance) {
342
- const renderFlags = (flags) => {
343
- if (flags.length === 0)
344
- return 'No specific flags identified.';
345
- return flags.map(f => {
346
- const labels = {
347
- 'action-required': ' `ACTION REQUIRED`',
348
- 'warning': ' `REVIEW`',
349
- 'clarification-needed': ' `NEEDS CLARIFICATION`',
350
- };
351
- const label = labels[f.severity] ?? '';
352
- return `- **${f.framework}**${label}\n ${f.description}`;
353
- }).join('\n\n');
453
+ const CATEGORIES = [
454
+ { key: 'privacy', title: 'Privacy' },
455
+ { key: 'ip', title: 'IP' },
456
+ { key: 'consumer-protection', title: 'Consumer Protection' },
457
+ { key: 'sector-specific', title: 'Sector-Specific' },
458
+ ];
459
+ // ─── Applicability Summary Table ─────────────────────────────────────────
460
+ /** Human-readable descriptions for why a framework didn't fire. */
461
+ const NOT_TRIGGERED_REASONS = {
462
+ 'gdpr': 'No personal data signals detected',
463
+ 'eu-ai-act': 'No applicable signals detected',
464
+ };
465
+ /** Short applicability condition for mandatory frameworks that DID fire. */
466
+ const APPLICABILITY_CONDITIONS = {
467
+ 'gdpr': 'If you serve EU data subjects',
468
+ 'eu-ai-act': 'If AI placed on EU market or outputs used in EU',
469
+ };
470
+ /** Map finding types to human-readable gap descriptions. */
471
+ const GAP_LABELS = {
472
+ 'excessive-access': 'Excessive permissions',
473
+ 'write-risk': 'Write operation risks',
474
+ 'sensitive-data': 'Data handling',
475
+ 'scope-creep': 'Scope exceeds purpose',
476
+ 'decisions-about-people': 'Automated decision-making',
477
+ 'regulatory-flags': 'Regulatory concerns',
478
+ };
479
+ /** Excluded from gap counting — always fires as methodology anchor, not a real gap. */
480
+ const GAP_EXCLUDED = new Set(['risk-score']);
481
+ function getGaps(frameworkId, allFlags) {
482
+ const flags = allFlags.filter(f => f.frameworkId === frameworkId && !GAP_EXCLUDED.has(f.triggeredBy));
483
+ // Also exclude decisions-about-people when it says "No decisions" (impact = none)
484
+ const meaningful = flags.filter(f => !(f.triggeredBy === 'decisions-about-people' && /no decisions about people/i.test(f.description)));
485
+ const uniqueTypes = [...new Set(meaningful.map(f => f.triggeredBy))];
486
+ return uniqueTypes.map(t => GAP_LABELS[t] ?? t);
487
+ }
488
+ function formatGaps(gaps) {
489
+ if (gaps.length === 0)
490
+ return { status: '✅ No gaps', details: '—' };
491
+ return {
492
+ status: `⚠️ ${gaps.length} gap${gaps.length > 1 ? 's' : ''}`,
493
+ details: gaps.join(', '),
354
494
  };
355
- return `## Regulatory Compliance
356
-
357
- > This section highlights potential regulatory implications based on interview data. It is advisory — consult qualified legal counsel for compliance decisions.
358
-
359
- ### EU (EU AI Act + GDPR)
360
-
361
- ${renderFlags(compliance.eu)}
362
-
363
- ### US (SOC 2 + State AI Laws)
495
+ }
496
+ function renderApplicabilitySummary(c) {
497
+ const activated = new Set(c.frameworksActivated ?? []);
498
+ const allFlags = (c.all ?? []);
499
+ const mandatoryFrameworks = [
500
+ { id: 'eu-ai-act', name: 'EU AI Act' },
501
+ { id: 'gdpr', name: 'GDPR' },
502
+ ];
503
+ const voluntaryFrameworks = [
504
+ { id: 'iso-42001', name: 'ISO/IEC 42001' },
505
+ { id: 'aiuc-1', name: 'AIUC-1 (Q2-2026)' },
506
+ { id: 'nist-ai-rmf', name: 'NIST AI RMF' },
507
+ ];
508
+ // EU AI Act classification scope label — single line replaces the prior
509
+ // two-entry split (`eu-ai-act` + `eu-ai-act-high-risk`).
510
+ const euClassification = c.euAiActClassification;
511
+ const mandatoryRows = mandatoryFrameworks.map(fw => {
512
+ const isActive = activated.has(fw.id);
513
+ if (!isActive) {
514
+ const reason = NOT_TRIGGERED_REASONS[fw.id] ?? 'No matching signals';
515
+ return `| ${fw.name} | ✅ Not applicable | ${reason} |`;
516
+ }
517
+ const gaps = getGaps(fw.id, allFlags);
518
+ let displayName = fw.name;
519
+ if (fw.id === 'eu-ai-act' && euClassification) {
520
+ const cls = euClassification.classification;
521
+ if (cls === 'high-risk' && euClassification.annexIIICategories.length > 0) {
522
+ displayName = `${fw.name} — High-Risk (Annex III ${euClassification.annexIIICategories.join(', ')})`;
523
+ }
524
+ else if (cls === 'limited') {
525
+ displayName = `${fw.name} — Limited-Risk (Art. 50 transparency)`;
526
+ }
527
+ else if (cls === 'prohibited') {
528
+ displayName = `${fw.name} — Prohibited Practice`;
529
+ }
530
+ }
531
+ if (gaps.length > 0) {
532
+ const condition = APPLICABILITY_CONDITIONS[fw.id] ?? '';
533
+ return `| ${displayName} | ⚠️ ${gaps.length} gap${gaps.length > 1 ? 's' : ''} | ${gaps.join(', ')} — ${condition} |`;
534
+ }
535
+ const condition = APPLICABILITY_CONDITIONS[fw.id] ?? 'Check applicability';
536
+ return `| ${displayName} | ⚠️ Check | ${condition} |`;
537
+ });
538
+ const voluntaryRows = voluntaryFrameworks.map(fw => {
539
+ const gaps = getGaps(fw.id, allFlags);
540
+ const { status, details } = formatGaps(gaps);
541
+ return `| ${fw.name} | ${status} | ${details} |`;
542
+ });
543
+ return `### Applicability Summary
364
544
 
365
- ${renderFlags(compliance.us)}
545
+ | Framework | Status | Gaps Found |
546
+ |-----------|--------|------------|
547
+ | **Mandatory Law** | | |
548
+ ${mandatoryRows.join('\n')}
549
+ | **Voluntary Frameworks** | | |
550
+ ${voluntaryRows.join('\n')}`;
551
+ }
552
+ // ─── Finding-first detail (replaces framework-first tier sections) ────────
553
+ /**
554
+ * Build agent-specific gap description from actual report data.
555
+ * Falls back to generic text if no specific context available.
556
+ */
557
+ function buildGapDescription(findingType, report) {
558
+ const systems = report?.systems?.filter(isBusinessSystem) ?? [];
559
+ const systemNames = systems.map(s => s.systemId).join(', ');
560
+ const excessiveScopes = systems.flatMap(s => s.scopesDelta?.map(d => `${s.systemId}: ${d}`) ?? []);
561
+ const writes = systems.flatMap(s => s.writeOperations?.map(w => `${w.operation} → ${w.target}`) ?? []);
562
+ const hasIrreversible = systems.some(s => s.writeOperations?.some(w => !w.reversible));
563
+ const dataSensitivities = [...new Set(systems.map(s => s.dataSensitivity).filter(Boolean))];
564
+ const decisionDetails = report?.decisionMakingDetails ?? '';
565
+ switch (findingType) {
566
+ case 'excessive-access':
567
+ if (excessiveScopes.length > 0) {
568
+ return `Agent holds permissions beyond stated need on ${systems.length} system(s). Excessive scopes detected: ${excessiveScopes.join('; ')}. Narrow each to the minimum required scope.`;
569
+ }
570
+ return `Agent holds permissions beyond stated need on ${systemNames || 'connected systems'}. Review and narrow scopes to the minimum required (least-privilege).`;
571
+ case 'write-risk':
572
+ if (writes.length > 0) {
573
+ const qualifier = hasIrreversible ? 'including irreversible operations' : 'all reported as reversible';
574
+ return `Agent performs ${writes.length} write operation(s) (${qualifier}): ${writes.join('; ')}. Require approval, monitoring, and rollback paths for high-impact operations.`;
575
+ }
576
+ return 'Write operations detected that can affect users or downstream systems. Require approval, monitoring, and rollback paths.';
577
+ case 'sensitive-data':
578
+ if (dataSensitivities.length > 0) {
579
+ return `Agent processes ${dataSensitivities.join(', ')} data across ${systemNames || 'connected systems'}. Ensure lawful basis under GDPR Art. 6, data minimization (Art. 5(1)(c)), and breach-readiness (Art. 33).`;
580
+ }
581
+ return 'Agent processes personal data. Ensure lawful basis, data minimization, and breach-readiness.';
582
+ case 'scope-creep':
583
+ return `Requested scopes on ${systemNames || 'one or more systems'} exceed what is needed for the stated purpose. Review purpose-limitation (GDPR Art. 5(1)(b)) and change-management process.`;
584
+ case 'decisions-about-people':
585
+ if (decisionDetails) {
586
+ return `Agent makes or influences automated decisions affecting individuals: "${decisionDetails.slice(0, 150)}". Requires human oversight, contestability, transparency, and data-subject rights (GDPR Art. 22).`;
587
+ }
588
+ return 'Agent makes or influences automated decisions affecting individuals. Requires human oversight, contestability, transparency, and data-subject rights.';
589
+ case 'regulatory-flags':
590
+ return 'Agent may operate in a regulated domain. Clarify the agent\'s domain to determine sector-specific obligations.';
591
+ default:
592
+ return '';
593
+ }
594
+ }
595
+ /** Short framework display names for the "Affects" line. */
596
+ function frameworkShortName(id) {
597
+ const names = {
598
+ 'eu-ai-act': 'EU AI Act',
599
+ 'gdpr': 'GDPR',
600
+ 'iso-42001': 'ISO 42001',
601
+ 'aiuc-1': 'AIUC-1 (Q2-2026)',
602
+ 'nist-ai-rmf': 'NIST AI RMF',
603
+ };
604
+ return names[id] ?? id;
605
+ }
606
+ function renderFindingFirstDetail(c, report) {
607
+ const allFlags = (c.all ?? []);
608
+ // Group flags by finding type (triggeredBy)
609
+ const byFinding = new Map();
610
+ for (const f of allFlags) {
611
+ if (GAP_EXCLUDED.has(f.triggeredBy))
612
+ continue;
613
+ if (f.triggeredBy === 'decisions-about-people' && /no decisions about people/i.test(f.description))
614
+ continue;
615
+ const arr = byFinding.get(f.triggeredBy) ?? [];
616
+ arr.push(f);
617
+ byFinding.set(f.triggeredBy, arr);
618
+ }
619
+ if (byFinding.size === 0) {
620
+ return `### Compliance Detail\n\n_No compliance gaps identified from current signals._\n`;
621
+ }
622
+ let out = `### Compliance Detail\n\n`;
623
+ for (const [findingType, flags] of byFinding) {
624
+ const label = GAP_LABELS[findingType] ?? findingType;
625
+ const description = buildGapDescription(findingType, report);
626
+ // Group controls by framework for the "Affects" line.
627
+ // Reviewer feedback (2026-04-25): the prior "+N more" truncation
628
+ // ("AIUC-1 (A001, A002, A005, +1 more)") hides the very citations the
629
+ // report is asserting — in an audit deliverable, you don't redact
630
+ // your evidence. The earlier AAP-43 P2 #9 cap (3 per framework) was
631
+ // motivated by readability, not by citation hygiene. With the
632
+ // table-layout: fixed + overflow-wrap CSS now in place, long control
633
+ // lists wrap cleanly inside their cells, so we show the full list.
634
+ const byFramework = new Map();
635
+ for (const f of flags) {
636
+ const fwName = frameworkShortName(f.frameworkId);
637
+ const existing = byFramework.get(fwName) ?? [];
638
+ for (const ctrl of (f.controlIds ?? [])) {
639
+ if (!existing.includes(ctrl))
640
+ existing.push(ctrl);
641
+ }
642
+ byFramework.set(fwName, existing);
643
+ }
644
+ const affectsParts = [...byFramework.entries()].map(([fw, ctrls]) => ctrls.length === 0 ? fw : `${fw} (${ctrls.join(', ')})`);
645
+ out += `#### ${label}\n\n`;
646
+ out += `${description}\n\n`;
647
+ out += `**Affects:** ${affectsParts.join(' · ')}\n\n`;
648
+ }
649
+ return out;
650
+ }
651
+ // ─── Obligations Requiring Further Review ─────────────────────────────────
652
+ function renderObligationsChecklist(c, report) {
653
+ const activated = new Set(c.frameworksActivated ?? []);
654
+ const rows = [];
655
+ // AAP-43 P1 #3: GDPR obligations are signal-gated, not dumped as a 14-row
656
+ // boilerplate. Each row requires an explicit signal; if no PII/decisions/
657
+ // transfer signals fire, the table is skipped entirely.
658
+ const hasGdpr = activated.has('gdpr');
659
+ const signals = c.signals;
660
+ if (hasGdpr && signals) {
661
+ // ── PII-driven obligations ──────────────────────────────────────────
662
+ if (signals.hasPII) {
663
+ rows.push({ obligation: 'GDPR Art. 6', action: 'Decide and document WHY you are allowed to process this data (e.g. legitimate business interest — must document a balancing test)' });
664
+ rows.push({ obligation: 'GDPR Art. 13/14', action: 'Tell people you are collecting their data: what, why, how long, and their rights' });
665
+ rows.push({ obligation: 'GDPR Art. 15', action: 'Be ready to show someone all data you hold on them if they ask' });
666
+ rows.push({ obligation: 'GDPR Art. 17', action: "Be ready to delete someone's data from all systems if they ask" });
667
+ rows.push({ obligation: 'GDPR Art. 30', action: 'Keep a written log of what personal data you process, why, and who has access' });
668
+ rows.push({ obligation: 'GDPR Art. 5(1)(e)', action: 'Set rules for how long you keep data — then actually delete it on schedule' });
669
+ }
670
+ // ── Profiling / automated decisions ─────────────────────────────────
671
+ if (signals.hasDecisionsAboutPeople) {
672
+ rows.push({ obligation: 'GDPR Art. 21', action: 'Let people opt out of being profiled for sales/marketing — you must stop if they object' });
673
+ }
674
+ // ── Processor contracts ─────────────────────────────────────────────
675
+ if (signals.hasPII && signals.hasExternalProcessors) {
676
+ rows.push({ obligation: 'GDPR Art. 28', action: 'Sign data processing contracts with every service you send data to (Google, Apify, etc.)' });
677
+ }
678
+ // ── DPIA: large-scale OR decisions OR sensitive PII ─────────────────
679
+ if (signals.hasLargeScaleProcessing || signals.hasDecisionsAboutPeople || signals.hasSensitivePII) {
680
+ rows.push({ obligation: 'GDPR Art. 35', action: 'Do a privacy impact assessment before going live (large-scale / profiling / sensitive data → likely required)' });
681
+ }
682
+ // ── International transfer ──────────────────────────────────────────
683
+ if (signals.hasPII && signals.hasInternationalTransfer) {
684
+ rows.push({ obligation: 'GDPR Arts. 44-49', action: 'Data leaves the EU (e.g. to US-based Google/Apify) — you need a legal basis for that transfer (SCCs, adequacy decision, etc.)' });
685
+ }
686
+ // ── Art. 22 automated-decisions safeguard ───────────────────────────
687
+ if (signals.hasDecisionsAboutPeople) {
688
+ rows.push({ obligation: 'GDPR Art. 22', action: 'AI makes decisions about people: ensure a human can review, people can contest, and the logic is explainable' });
689
+ }
690
+ }
691
+ // Always applicable — baseline operational obligations
692
+ rows.push({ obligation: 'Credentials', action: 'Store API keys/tokens in a secrets manager (not in code or env files), rotate them regularly' });
693
+ rows.push({ obligation: 'Platform ToS', action: 'Check you are not violating the rules of LinkedIn, Google, or other connected services (scraping, rate limits, usage policies)' });
694
+ rows.push({ obligation: 'Incident response', action: 'Have a plan: if data leaks, who do you notify and within what timeframe? (EU: 72 hours to regulator)' });
695
+ if (rows.length === 0)
696
+ return '';
697
+ const tableRows = rows.map(r => `| ${r.obligation} | ${r.action} |`).join('\n');
698
+ return `### Obligations Requiring Further Review
366
699
 
367
- ### UK (UK GDPR + ICO Guidance)
700
+ The following cannot be assessed from this interview alone — the deployer must address independently:
368
701
 
369
- ${renderFlags(compliance.uk)}`;
702
+ | Obligation | Action Required |
703
+ |------------|-----------------|
704
+ ${tableRows}`;
705
+ }
706
+ export function renderStructuredCompliance(c, report) {
707
+ return [
708
+ `## Regulatory Compliance`,
709
+ ``,
710
+ `### Methodology`,
711
+ ``,
712
+ `Findings are anchored to EU AI Act 2024/1689, GDPR 2016/679, ISO/IEC 42001 (AI management system), AIUC-1 (agent-native standard, pinned to Q2-2026 release 2026-04-15), and NIST AI RMF 1.0 (US-origin voluntary risk-management framework; GOVERN/MAP/MEASURE/MANAGE). Mapping version: \`${c.mappingVersion}\`. EU AI Act is a single framework entry; Annex III high-risk obligations are surfaced as a classification scope label on that entry (replacing the prior two-entry split). Control mappings are indicative — they show which framework clauses a finding typically activates and do not constitute legal advice.`,
713
+ ``,
714
+ renderApplicabilitySummary(c),
715
+ ``,
716
+ renderFindingFirstDetail(c, report),
717
+ renderObligationsChecklist(c, report),
718
+ ].join('\n');
719
+ }
720
+ function renderRegulatoryCompliance(compliance, report) {
721
+ return renderStructuredCompliance(compliance, report);
370
722
  }
371
723
  // ─── Disclaimer ─────────────────────────────────────────────────────────────
372
724
  function renderDisclaimer() {