@nebulacomponents/citable 1.18.1 → 1.19.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 (193) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/universal/.agents/skills/citable/SKILL.md +1 -1
  3. package/dist/universal/.agents/skills/citable/VERSION +1 -1
  4. package/dist/universal/.agents/skills/citable/commands/artifact-interchange.md +5 -0
  5. package/dist/universal/.agents/skills/citable/commands/remediation.md +1 -1
  6. package/dist/universal/.agents/skills/citable/commands/sow.md +2 -2
  7. package/dist/universal/.agents/skills/citable/manifest.json +15 -10
  8. package/dist/universal/.agents/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
  9. package/dist/universal/.agents/skills/citable/schemas/browser-network-events.schema.json +142 -0
  10. package/dist/universal/.agents/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
  11. package/dist/universal/.agents/skills/citable/schemas/browser-state-observations.schema.json +97 -0
  12. package/dist/universal/.agents/skills/citable/schemas/cro-report.schema.json +112 -0
  13. package/dist/universal/.agents/skills/citable/schemas/search-report.schema.json +99 -0
  14. package/dist/universal/.agents/skills/citable/schemas/sow.schema.json +3 -2
  15. package/dist/universal/.claude/agents/citable/manifest.json +2 -2
  16. package/dist/universal/.claude/skills/citable/SKILL.md +1 -1
  17. package/dist/universal/.claude/skills/citable/VERSION +1 -1
  18. package/dist/universal/.claude/skills/citable/commands/artifact-interchange.md +5 -0
  19. package/dist/universal/.claude/skills/citable/commands/remediation.md +1 -1
  20. package/dist/universal/.claude/skills/citable/commands/sow.md +2 -2
  21. package/dist/universal/.claude/skills/citable/manifest.json +15 -10
  22. package/dist/universal/.claude/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
  23. package/dist/universal/.claude/skills/citable/schemas/browser-network-events.schema.json +142 -0
  24. package/dist/universal/.claude/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
  25. package/dist/universal/.claude/skills/citable/schemas/browser-state-observations.schema.json +97 -0
  26. package/dist/universal/.claude/skills/citable/schemas/cro-report.schema.json +112 -0
  27. package/dist/universal/.claude/skills/citable/schemas/search-report.schema.json +99 -0
  28. package/dist/universal/.claude/skills/citable/schemas/sow.schema.json +3 -2
  29. package/dist/universal/.cursor/skills/citable/SKILL.md +1 -1
  30. package/dist/universal/.cursor/skills/citable/VERSION +1 -1
  31. package/dist/universal/.cursor/skills/citable/commands/artifact-interchange.md +5 -0
  32. package/dist/universal/.cursor/skills/citable/commands/remediation.md +1 -1
  33. package/dist/universal/.cursor/skills/citable/commands/sow.md +2 -2
  34. package/dist/universal/.cursor/skills/citable/manifest.json +15 -10
  35. package/dist/universal/.cursor/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
  36. package/dist/universal/.cursor/skills/citable/schemas/browser-network-events.schema.json +142 -0
  37. package/dist/universal/.cursor/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
  38. package/dist/universal/.cursor/skills/citable/schemas/browser-state-observations.schema.json +97 -0
  39. package/dist/universal/.cursor/skills/citable/schemas/cro-report.schema.json +112 -0
  40. package/dist/universal/.cursor/skills/citable/schemas/search-report.schema.json +99 -0
  41. package/dist/universal/.cursor/skills/citable/schemas/sow.schema.json +3 -2
  42. package/dist/universal/.gemini/skills/citable/SKILL.md +1 -1
  43. package/dist/universal/.gemini/skills/citable/VERSION +1 -1
  44. package/dist/universal/.gemini/skills/citable/commands/artifact-interchange.md +5 -0
  45. package/dist/universal/.gemini/skills/citable/commands/remediation.md +1 -1
  46. package/dist/universal/.gemini/skills/citable/commands/sow.md +2 -2
  47. package/dist/universal/.gemini/skills/citable/manifest.json +15 -10
  48. package/dist/universal/.gemini/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
  49. package/dist/universal/.gemini/skills/citable/schemas/browser-network-events.schema.json +142 -0
  50. package/dist/universal/.gemini/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
  51. package/dist/universal/.gemini/skills/citable/schemas/browser-state-observations.schema.json +97 -0
  52. package/dist/universal/.gemini/skills/citable/schemas/cro-report.schema.json +112 -0
  53. package/dist/universal/.gemini/skills/citable/schemas/search-report.schema.json +99 -0
  54. package/dist/universal/.gemini/skills/citable/schemas/sow.schema.json +3 -2
  55. package/dist/universal/.github/skills/citable/SKILL.md +1 -1
  56. package/dist/universal/.github/skills/citable/VERSION +1 -1
  57. package/dist/universal/.github/skills/citable/commands/artifact-interchange.md +5 -0
  58. package/dist/universal/.github/skills/citable/commands/remediation.md +1 -1
  59. package/dist/universal/.github/skills/citable/commands/sow.md +2 -2
  60. package/dist/universal/.github/skills/citable/manifest.json +15 -10
  61. package/dist/universal/.github/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
  62. package/dist/universal/.github/skills/citable/schemas/browser-network-events.schema.json +142 -0
  63. package/dist/universal/.github/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
  64. package/dist/universal/.github/skills/citable/schemas/browser-state-observations.schema.json +97 -0
  65. package/dist/universal/.github/skills/citable/schemas/cro-report.schema.json +112 -0
  66. package/dist/universal/.github/skills/citable/schemas/search-report.schema.json +99 -0
  67. package/dist/universal/.github/skills/citable/schemas/sow.schema.json +3 -2
  68. package/dist/universal/.kiro/skills/citable/SKILL.md +1 -1
  69. package/dist/universal/.kiro/skills/citable/VERSION +1 -1
  70. package/dist/universal/.kiro/skills/citable/commands/artifact-interchange.md +5 -0
  71. package/dist/universal/.kiro/skills/citable/commands/remediation.md +1 -1
  72. package/dist/universal/.kiro/skills/citable/commands/sow.md +2 -2
  73. package/dist/universal/.kiro/skills/citable/manifest.json +15 -10
  74. package/dist/universal/.kiro/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
  75. package/dist/universal/.kiro/skills/citable/schemas/browser-network-events.schema.json +142 -0
  76. package/dist/universal/.kiro/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
  77. package/dist/universal/.kiro/skills/citable/schemas/browser-state-observations.schema.json +97 -0
  78. package/dist/universal/.kiro/skills/citable/schemas/cro-report.schema.json +112 -0
  79. package/dist/universal/.kiro/skills/citable/schemas/search-report.schema.json +99 -0
  80. package/dist/universal/.kiro/skills/citable/schemas/sow.schema.json +3 -2
  81. package/dist/universal/.opencode/skills/citable/SKILL.md +1 -1
  82. package/dist/universal/.opencode/skills/citable/VERSION +1 -1
  83. package/dist/universal/.opencode/skills/citable/commands/artifact-interchange.md +5 -0
  84. package/dist/universal/.opencode/skills/citable/commands/remediation.md +1 -1
  85. package/dist/universal/.opencode/skills/citable/commands/sow.md +2 -2
  86. package/dist/universal/.opencode/skills/citable/manifest.json +15 -10
  87. package/dist/universal/.opencode/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
  88. package/dist/universal/.opencode/skills/citable/schemas/browser-network-events.schema.json +142 -0
  89. package/dist/universal/.opencode/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
  90. package/dist/universal/.opencode/skills/citable/schemas/browser-state-observations.schema.json +97 -0
  91. package/dist/universal/.opencode/skills/citable/schemas/cro-report.schema.json +112 -0
  92. package/dist/universal/.opencode/skills/citable/schemas/search-report.schema.json +99 -0
  93. package/dist/universal/.opencode/skills/citable/schemas/sow.schema.json +3 -2
  94. package/dist/universal/.pi/agent/skills/citable/SKILL.md +1 -1
  95. package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
  96. package/dist/universal/.pi/agent/skills/citable/commands/artifact-interchange.md +5 -0
  97. package/dist/universal/.pi/agent/skills/citable/commands/remediation.md +1 -1
  98. package/dist/universal/.pi/agent/skills/citable/commands/sow.md +2 -2
  99. package/dist/universal/.pi/agent/skills/citable/manifest.json +15 -10
  100. package/dist/universal/.pi/agent/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
  101. package/dist/universal/.pi/agent/skills/citable/schemas/browser-network-events.schema.json +142 -0
  102. package/dist/universal/.pi/agent/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
  103. package/dist/universal/.pi/agent/skills/citable/schemas/browser-state-observations.schema.json +97 -0
  104. package/dist/universal/.pi/agent/skills/citable/schemas/cro-report.schema.json +112 -0
  105. package/dist/universal/.pi/agent/skills/citable/schemas/search-report.schema.json +99 -0
  106. package/dist/universal/.pi/agent/skills/citable/schemas/sow.schema.json +3 -2
  107. package/dist/universal/.qoder/skills/citable/SKILL.md +1 -1
  108. package/dist/universal/.qoder/skills/citable/VERSION +1 -1
  109. package/dist/universal/.qoder/skills/citable/commands/artifact-interchange.md +5 -0
  110. package/dist/universal/.qoder/skills/citable/commands/remediation.md +1 -1
  111. package/dist/universal/.qoder/skills/citable/commands/sow.md +2 -2
  112. package/dist/universal/.qoder/skills/citable/manifest.json +15 -10
  113. package/dist/universal/.qoder/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
  114. package/dist/universal/.qoder/skills/citable/schemas/browser-network-events.schema.json +142 -0
  115. package/dist/universal/.qoder/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
  116. package/dist/universal/.qoder/skills/citable/schemas/browser-state-observations.schema.json +97 -0
  117. package/dist/universal/.qoder/skills/citable/schemas/cro-report.schema.json +112 -0
  118. package/dist/universal/.qoder/skills/citable/schemas/search-report.schema.json +99 -0
  119. package/dist/universal/.qoder/skills/citable/schemas/sow.schema.json +3 -2
  120. package/dist/universal/.rovodev/skills/citable/SKILL.md +1 -1
  121. package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
  122. package/dist/universal/.rovodev/skills/citable/commands/artifact-interchange.md +5 -0
  123. package/dist/universal/.rovodev/skills/citable/commands/remediation.md +1 -1
  124. package/dist/universal/.rovodev/skills/citable/commands/sow.md +2 -2
  125. package/dist/universal/.rovodev/skills/citable/manifest.json +15 -10
  126. package/dist/universal/.rovodev/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
  127. package/dist/universal/.rovodev/skills/citable/schemas/browser-network-events.schema.json +142 -0
  128. package/dist/universal/.rovodev/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
  129. package/dist/universal/.rovodev/skills/citable/schemas/browser-state-observations.schema.json +97 -0
  130. package/dist/universal/.rovodev/skills/citable/schemas/cro-report.schema.json +112 -0
  131. package/dist/universal/.rovodev/skills/citable/schemas/search-report.schema.json +99 -0
  132. package/dist/universal/.rovodev/skills/citable/schemas/sow.schema.json +3 -2
  133. package/dist/universal/.trae/skills/citable/SKILL.md +1 -1
  134. package/dist/universal/.trae/skills/citable/VERSION +1 -1
  135. package/dist/universal/.trae/skills/citable/commands/artifact-interchange.md +5 -0
  136. package/dist/universal/.trae/skills/citable/commands/remediation.md +1 -1
  137. package/dist/universal/.trae/skills/citable/commands/sow.md +2 -2
  138. package/dist/universal/.trae/skills/citable/manifest.json +15 -10
  139. package/dist/universal/.trae/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
  140. package/dist/universal/.trae/skills/citable/schemas/browser-network-events.schema.json +142 -0
  141. package/dist/universal/.trae/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
  142. package/dist/universal/.trae/skills/citable/schemas/browser-state-observations.schema.json +97 -0
  143. package/dist/universal/.trae/skills/citable/schemas/cro-report.schema.json +112 -0
  144. package/dist/universal/.trae/skills/citable/schemas/search-report.schema.json +99 -0
  145. package/dist/universal/.trae/skills/citable/schemas/sow.schema.json +3 -2
  146. package/dist/universal/.trae-cn/skills/citable/SKILL.md +1 -1
  147. package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
  148. package/dist/universal/.trae-cn/skills/citable/commands/artifact-interchange.md +5 -0
  149. package/dist/universal/.trae-cn/skills/citable/commands/remediation.md +1 -1
  150. package/dist/universal/.trae-cn/skills/citable/commands/sow.md +2 -2
  151. package/dist/universal/.trae-cn/skills/citable/manifest.json +15 -10
  152. package/dist/universal/.trae-cn/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
  153. package/dist/universal/.trae-cn/skills/citable/schemas/browser-network-events.schema.json +142 -0
  154. package/dist/universal/.trae-cn/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
  155. package/dist/universal/.trae-cn/skills/citable/schemas/browser-state-observations.schema.json +97 -0
  156. package/dist/universal/.trae-cn/skills/citable/schemas/cro-report.schema.json +112 -0
  157. package/dist/universal/.trae-cn/skills/citable/schemas/search-report.schema.json +99 -0
  158. package/dist/universal/.trae-cn/skills/citable/schemas/sow.schema.json +3 -2
  159. package/dist/universal/manifest.json +184 -124
  160. package/package.json +2 -1
  161. package/schemas/browser-evidence-plan.schema.json +186 -3
  162. package/schemas/browser-network-events.schema.json +142 -0
  163. package/schemas/browser-runtime-events.schema.json +112 -0
  164. package/schemas/browser-state-observations.schema.json +97 -0
  165. package/schemas/cro-report.schema.json +112 -0
  166. package/schemas/search-report.schema.json +99 -0
  167. package/schemas/sow.schema.json +3 -2
  168. package/skill/SKILL.md +1 -1
  169. package/skill/commands/artifact-interchange.md +5 -0
  170. package/skill/commands/remediation.md +1 -1
  171. package/skill/commands/sow.md +2 -2
  172. package/src/analysis/behavioral.js +5 -4
  173. package/src/analysis/eeat.js +10 -9
  174. package/src/analysis/offpage.js +19 -36
  175. package/src/analysis/readiness.js +8 -2
  176. package/src/artifacts/verifyCustomerArtifact.js +283 -0
  177. package/src/cli/index.js +8 -1
  178. package/src/observations/browser/correlation.js +83 -0
  179. package/src/observations/browser/networkCollector.js +413 -0
  180. package/src/observations/browser/runtimeEventCollector.js +337 -0
  181. package/src/observations/browser/sanitizer.js +322 -0
  182. package/src/observations/browser/stateCollector.js +386 -0
  183. package/src/observations/browserJourney.js +131 -3
  184. package/src/reporting/executiveCroReport.js +393 -229
  185. package/src/reporting/executiveExport.js +6 -2
  186. package/src/reporting/executiveSearchReport.js +519 -271
  187. package/src/shared/domainUtils.js +140 -0
  188. package/src/shared/epistemicStatus.js +111 -0
  189. package/src/shared/evidenceSourceResolver.js +286 -0
  190. package/src/shared/htmlEscape.js +65 -0
  191. package/src/shared/runPackageVerifier.js +153 -0
  192. package/src/sow/admissibilityGate.js +20 -2
  193. package/src/sow/generateSow.js +125 -159
@@ -0,0 +1,153 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readJson, sha256File } from './io.js';
4
+ import { validateAgainst } from './schemaValidator.js';
5
+
6
+ export class RunVerificationError extends Error {
7
+ constructor(message, details = {}) {
8
+ super(message);
9
+ this.name = 'RunVerificationError';
10
+ this.code = details.code || 'RUN_VERIFICATION_FAILED';
11
+ this.details = details;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Verifies the cryptographic and structural integrity of an audit run evidence package.
17
+ *
18
+ * Checks:
19
+ * 1. Existence of run directory and manifest.json
20
+ * 2. Manifest compliance with run.schema.json
21
+ * 3. Existence and validity of checksums.json
22
+ * 4. Exact sha256 hash match for all sealed artifacts in checksums.json
23
+ * 5. Run completion status (rejecting failed or partial runs in contractual contexts)
24
+ * 6. Findings structural validity
25
+ */
26
+ export function verifyRunPackage(runDir, options = {}) {
27
+ const {
28
+ requireFindings = true,
29
+ requireCompleted = false,
30
+ requireChecksums = true,
31
+ } = options;
32
+
33
+ if (!runDir || !fs.existsSync(runDir)) {
34
+ throw new RunVerificationError(`Run directory not found: ${runDir}`, { code: 'RUN_NOT_FOUND', runDir });
35
+ }
36
+
37
+ const manifestPath = path.join(runDir, 'manifest.json');
38
+ if (!fs.existsSync(manifestPath)) {
39
+ throw new RunVerificationError(`Run package is missing manifest.json at ${manifestPath}`, {
40
+ code: 'MANIFEST_MISSING',
41
+ runDir,
42
+ });
43
+ }
44
+
45
+ let manifest;
46
+ try {
47
+ manifest = readJson(manifestPath);
48
+ } catch (err) {
49
+ throw new RunVerificationError(`Run manifest.json is invalid JSON: ${err.message}`, {
50
+ code: 'MANIFEST_INVALID',
51
+ runDir,
52
+ });
53
+ }
54
+
55
+ const schemaValidation = validateAgainst('run.schema.json', manifest);
56
+ if (!schemaValidation.valid) {
57
+ throw new RunVerificationError(`Run manifest violates run.schema.json: ${schemaValidation.errors.join('; ')}`, {
58
+ code: 'MANIFEST_SCHEMA_INVALID',
59
+ errors: schemaValidation.errors,
60
+ runDir,
61
+ });
62
+ }
63
+
64
+ if (requireCompleted && manifest.status !== 'completed' && manifest.status !== 'success') {
65
+ throw new RunVerificationError(`Run status is "${manifest.status}"; expected completed/success`, {
66
+ code: 'RUN_INCOMPLETE',
67
+ status: manifest.status,
68
+ runDir,
69
+ });
70
+ }
71
+
72
+ // Verify checksums.json
73
+ const checksumsPath = path.join(runDir, 'checksums.json');
74
+ let checksumsVerified = false;
75
+ if (fs.existsSync(checksumsPath)) {
76
+ let checksums;
77
+ try {
78
+ checksums = readJson(checksumsPath);
79
+ } catch (err) {
80
+ throw new RunVerificationError(`checksums.json is invalid JSON: ${err.message}`, {
81
+ code: 'CHECKSUMS_INVALID',
82
+ runDir,
83
+ });
84
+ }
85
+
86
+ const tamperedFiles = [];
87
+ const missingFiles = [];
88
+
89
+ for (const [relPath, expectedHash] of Object.entries(checksums)) {
90
+ const artifactPath = path.join(runDir, relPath);
91
+ if (!fs.existsSync(artifactPath)) {
92
+ missingFiles.push(relPath);
93
+ continue;
94
+ }
95
+ const actualHash = sha256File(artifactPath);
96
+ if (actualHash !== expectedHash) {
97
+ tamperedFiles.push({ file: relPath, expected: expectedHash, actual: actualHash });
98
+ }
99
+ }
100
+
101
+ if (tamperedFiles.length > 0 || missingFiles.length > 0) {
102
+ throw new RunVerificationError(
103
+ `Cryptographic verification failed for run package: ${tamperedFiles.length} tampered files, ${missingFiles.length} missing files`,
104
+ {
105
+ code: 'CHECKSUM_MISMATCH',
106
+ tamperedFiles,
107
+ missingFiles,
108
+ runDir,
109
+ }
110
+ );
111
+ }
112
+ checksumsVerified = true;
113
+ } else if (requireChecksums) {
114
+ throw new RunVerificationError(`Run package is missing checksums.json at ${checksumsPath}`, {
115
+ code: 'CHECKSUMS_MISSING',
116
+ runDir,
117
+ });
118
+ }
119
+
120
+ // Findings verification if present or required
121
+ const findingsPath = path.join(runDir, 'findings.json');
122
+ let findings = null;
123
+ if (fs.existsSync(findingsPath)) {
124
+ try {
125
+ findings = readJson(findingsPath);
126
+ if (!Array.isArray(findings)) {
127
+ throw new Error('findings.json must contain an array of findings');
128
+ }
129
+ } catch (err) {
130
+ throw new RunVerificationError(`findings.json is malformed: ${err.message}`, {
131
+ code: 'FINDINGS_MALFORMED',
132
+ runDir,
133
+ });
134
+ }
135
+ } else if (requireFindings) {
136
+ throw new RunVerificationError(`Run package is missing findings.json at ${findingsPath}`, {
137
+ code: 'FINDINGS_MISSING',
138
+ runDir,
139
+ });
140
+ }
141
+
142
+ return {
143
+ valid: true,
144
+ runId: manifest.run_id,
145
+ timestamp: manifest.timestamp,
146
+ status: manifest.status,
147
+ manifest,
148
+ checksumsVerified,
149
+ findingsPath: fs.existsSync(findingsPath) ? findingsPath : null,
150
+ findingsCount: Array.isArray(findings) ? findings.length : 0,
151
+ findings,
152
+ };
153
+ }
@@ -104,6 +104,7 @@ export function evaluateScopeAdmissibility(findings = [], {
104
104
  excludedDetectors = [],
105
105
  roleMapping = null,
106
106
  ownerMappingVersion = '1.0',
107
+ rejectTemplateDefaultOwner = false,
107
108
  } = {}) {
108
109
  const admitted = [];
109
110
  const refused = [];
@@ -259,12 +260,15 @@ export function evaluateScopeAdmissibility(findings = [], {
259
260
  } else if (f.owner && f.owner !== 'UNRESOLVED') {
260
261
  resolvedOwner = f.owner;
261
262
  ownerSource = 'finding.owner';
263
+ } else if (roleMapping && (roleMapping[disciplineKey] || roleMapping[detectorId])) {
264
+ resolvedOwner = roleMapping[disciplineKey] || roleMapping[detectorId];
265
+ ownerSource = 'engagement_role_mapping';
262
266
  } else if (effectiveRoles[disciplineKey] && effectiveRoles[disciplineKey] !== 'UNRESOLVED') {
263
267
  resolvedOwner = effectiveRoles[disciplineKey];
264
- ownerSource = 'engagement_role_mapping';
268
+ ownerSource = 'built_in_template_default';
265
269
  } else if (effectiveRoles[detectorId] && effectiveRoles[detectorId] !== 'UNRESOLVED') {
266
270
  resolvedOwner = effectiveRoles[detectorId];
267
- ownerSource = 'engagement_role_mapping';
271
+ ownerSource = 'built_in_template_default';
268
272
  }
269
273
 
270
274
  if (!resolvedOwner || resolvedOwner === 'UNRESOLVED' || resolvedOwner.toLowerCase() === 'unassigned') {
@@ -280,6 +284,19 @@ export function evaluateScopeAdmissibility(findings = [], {
280
284
  continue;
281
285
  }
282
286
 
287
+ if (rejectTemplateDefaultOwner && ownerSource === 'built_in_template_default') {
288
+ refused.push({
289
+ finding_id: findingId,
290
+ detector_id: detectorId,
291
+ subject,
292
+ gate_failed: 'ownership_clarity',
293
+ refusal_code: 'REFUSE-TEMPLATE-DEFAULT-OWNER',
294
+ refusal_rationale: `Responsible delivery owner for finding ${findingId} relies on built-in template default. Contractual SOW requires an explicit finding owner or engagement role mapping.`,
295
+ suggested_handling: 'Specify delivery_owner on finding or map discipline in engagement role mapping.',
296
+ });
297
+ continue;
298
+ }
299
+
283
300
  // Gate 8b: Discipline Authorization Gate
284
301
  const rawDisciplines = Array.isArray(f.discipline)
285
302
  ? f.discipline
@@ -340,6 +357,7 @@ export function evaluateScopeAdmissibility(findings = [], {
340
357
  confidence,
341
358
  recommendation: remediation,
342
359
  acceptance_test: f.verification?.acceptance_test || `Execute closed-loop verification: citable verify remediation --finding ${detectorToRerun} --target ${subject} (Expect status: RESOLVED, zero regressions)`,
360
+ acceptance_basis: f.verification?.method ? 'empirical_verification_method' : (f.verification?.detector_to_rerun ? 'automated_detector_rerun' : 'deterministic_remediation_test'),
343
361
  owner: resolvedOwner,
344
362
  owner_source: ownerSource,
345
363
  owner_mapping_version: ownerMappingVersion,
@@ -6,6 +6,18 @@ import { runDetectors, indexTargets } from '../detectors/framework.js';
6
6
  import { evaluateScopeAdmissibility } from './admissibilityGate.js';
7
7
  import { readJson, nowIso, sha256 } from '../shared/io.js';
8
8
  import { validateAgainst } from '../shared/schemaValidator.js';
9
+ import {
10
+ resolveEvidenceSource,
11
+ getCanonicalRunTimestamp,
12
+ sortRunCandidatesChronologically,
13
+ } from '../shared/evidenceSourceResolver.js';
14
+ import {
15
+ escapeHtml,
16
+ escapeHtmlAttr,
17
+ sanitizeUrl,
18
+ escapeMarkdownTableCell,
19
+ sanitizeForMarkdown,
20
+ } from '../shared/htmlEscape.js';
9
21
 
10
22
  /**
11
23
  * Typed SOW Domain Errors
@@ -135,72 +147,7 @@ export function allocateMilestoneFees(totalUnits, count) {
135
147
  return fees;
136
148
  }
137
149
 
138
- /**
139
- * Extract canonical timestamp (epoch ms) from run package
140
- * Hierarchy:
141
- * 1. manifest.json `timestamp` or `created_at` ISO string
142
- * 2. Run ID timestamp prefix (YYYYMMDDTHHmmss)
143
- * 3. findings.json mtime
144
- * 4. run directory mtime
145
- */
146
- export function getCanonicalRunTimestamp(runDir, runId = '') {
147
- // 1. manifest.json
148
- const manifestPath = path.join(runDir, 'manifest.json');
149
- if (fs.existsSync(manifestPath)) {
150
- try {
151
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
152
- if (manifest.timestamp) {
153
- const ms = Date.parse(manifest.timestamp);
154
- if (!Number.isNaN(ms)) return ms;
155
- }
156
- if (manifest.created_at) {
157
- const ms = Date.parse(manifest.created_at);
158
- if (!Number.isNaN(ms)) return ms;
159
- }
160
- } catch {}
161
- }
162
-
163
- // 2. Run ID timestamp prefix (YYYYMMDDTHHmmss)
164
- const m = String(runId).match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/);
165
- if (m) {
166
- const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
167
- const ms = Date.parse(iso);
168
- if (!Number.isNaN(ms)) return ms;
169
- }
170
-
171
- // 3. findings.json mtime
172
- const findPath = path.join(runDir, 'findings.json');
173
- if (fs.existsSync(findPath)) {
174
- try {
175
- return fs.statSync(findPath).mtimeMs;
176
- } catch {}
177
- }
178
-
179
- // 4. Directory mtime
180
- try {
181
- return fs.statSync(runDir).mtimeMs;
182
- } catch {
183
- return 0;
184
- }
185
- }
186
-
187
- /**
188
- * Sort run candidate directory names by chronological timestamp descending,
189
- * with deterministic lexicographical tie-breaking.
190
- * Invariant:
191
- * latest run = max(canonical run timestamp)
192
- * tie = deterministic secondary key
193
- */
194
- export function sortRunCandidatesChronologically(runsDir, candidateRunIds = []) {
195
- return [...candidateRunIds].sort((a, b) => {
196
- const timeA = getCanonicalRunTimestamp(path.join(runsDir, a), a);
197
- const timeB = getCanonicalRunTimestamp(path.join(runsDir, b), b);
198
- if (timeB !== timeA) {
199
- return timeB - timeA; // Descending: latest timestamp first
200
- }
201
- return b.localeCompare(a); // Deterministic tie-breaker
202
- });
203
- }
150
+ export { getCanonicalRunTimestamp, sortRunCandidatesChronologically };
204
151
 
205
152
  /**
206
153
  * Verify cross-object runtime invariants before SOW export
@@ -244,6 +191,8 @@ export function validateSowInvariants(sow) {
244
191
  }
245
192
  const wpIds = new Set(sow.work_packages.map((wp) => wp.work_package_id));
246
193
  const delivIds = new Set(sow.deliverables.map((d) => d.deliverable_id));
194
+ const seenReqIds = new Set();
195
+ const seenFindingKeys = new Set();
247
196
  for (const row of sow.traceability_matrix) {
248
197
  if (!wpIds.has(row.work_package_id)) {
249
198
  throw new SowInvariantError(`Traceability matrix references unknown work package: ${row.work_package_id}`);
@@ -254,6 +203,15 @@ export function validateSowInvariants(sow) {
254
203
  if (!Array.isArray(row.evidence_ids) || row.evidence_ids.length === 0) {
255
204
  throw new SowInvariantError(`Traceability matrix row ${row.sow_requirement_id} lacks supporting evidence_ids`);
256
205
  }
206
+ if (seenReqIds.has(row.sow_requirement_id)) {
207
+ throw new SowInvariantError(`Duplicate sow_requirement_id in traceability matrix: ${row.sow_requirement_id}`);
208
+ }
209
+ seenReqIds.add(row.sow_requirement_id);
210
+ const findingKey = `${row.finding_id}:${row.target_subject || ''}`;
211
+ if (seenFindingKeys.has(findingKey)) {
212
+ throw new SowInvariantError(`Duplicate finding_id and subject in traceability matrix: ${findingKey}`);
213
+ }
214
+ seenFindingKeys.add(findingKey);
257
215
  }
258
216
 
259
217
  const milestones = sow.delivery_schedule?.milestones || [];
@@ -395,94 +353,100 @@ export async function generateSow(root, {
395
353
  integrity_hash: null,
396
354
  };
397
355
 
398
- // 1. Precedence: Direct findings supplied programmatically
399
- if (Array.isArray(inputFindings)) {
400
- findings = inputFindings;
401
- sourceProvenance.source_type = 'DIRECT_FINDINGS';
402
- sourceProvenance.source_identifier = 'in_memory';
403
- }
404
- // 2. Precedence: Explicit run requested via --run <id>
405
- else if (runId) {
406
- const runsDir = path.join(root, '.citable', 'runs');
407
- const runPath = path.join(runsDir, runId);
408
- if (!fs.existsSync(runPath)) {
356
+ let resolved;
357
+ try {
358
+ resolved = await resolveEvidenceSource(root, {
359
+ findings: inputFindings,
360
+ runId,
361
+ target,
362
+ live,
363
+ baseUrl,
364
+ refDate,
365
+ sample,
366
+ demo,
367
+ draft,
368
+ scopes: ['technical', 'seo', 'aeo', 'geo', 'schema', 'entity', 'cro'],
369
+ });
370
+ } catch (err) {
371
+ if (err.code === 'RUN_NOT_FOUND') {
409
372
  throw new RunNotFoundError(runId);
410
373
  }
411
- const findPath = path.join(runPath, 'findings.json');
412
- if (!fs.existsSync(findPath)) {
413
- throw new FindingsMissingError(runId, findPath);
374
+ if (err.code === 'FINDINGS_MISSING') {
375
+ throw new FindingsMissingError(runId, err.details?.filePath);
414
376
  }
415
- try {
416
- findings = readJson(findPath);
417
- if (!Array.isArray(findings)) throw new Error('findings.json root must be an array');
418
- sourceProvenance.source_type = 'HISTORICAL_RUN';
419
- sourceProvenance.source_identifier = runId;
420
- sourceProvenance.integrity_hash = sha256(fs.readFileSync(findPath));
421
- } catch (err) {
377
+ if (err.code === 'FINDINGS_INVALID') {
422
378
  throw new FindingsInvalidError(runId, err.message);
423
379
  }
424
- }
425
- // 3. Precedence: Live target inspection requested via --live or target
426
- else if (live || target) {
427
- if (!target) {
428
- throw new SowError('Target is required for live inspection (e.g. --target <url|dir>)', 'TARGET_REQUIRED');
429
- }
430
- try {
431
- const ctx = await buildContext(root, { target, baseUrl, refDate });
432
- if (!ctx?.site) {
433
- throw new Error(`Target ${target} did not produce a valid site context`);
434
- }
435
- indexTargets(ctx);
436
- const detectors = selectDetectors({ scopes: ['technical', 'seo', 'aeo', 'geo', 'schema', 'entity', 'cro'] });
437
- const res = runDetectors(detectors, ctx);
438
- findings = res.findings || [];
439
- sourceProvenance.source_type = 'LIVE_INSPECTION';
440
- sourceProvenance.source_identifier = target;
441
- } catch (err) {
380
+ if (err.code === 'LIVE_INSPECTION_FAILED') {
442
381
  throw new LiveInspectionFailedError(target, err.message);
443
382
  }
444
- }
445
- // 4. Precedence: Automatically load latest recorded run from .citable/runs if present
446
- // Invariant: latest run = max(canonical run timestamp), tie = deterministic secondary key
447
- else {
448
- const runsDir = path.join(root, '.citable', 'runs');
449
- if (fs.existsSync(runsDir)) {
450
- const candidates = fs.readdirSync(runsDir)
451
- .filter((d) => !d.startsWith('.') && fs.existsSync(path.join(runsDir, d, 'findings.json')));
452
- const sortedCandidates = sortRunCandidatesChronologically(runsDir, candidates);
453
-
454
- if (sortedCandidates.length > 0) {
455
- const latestRunId = sortedCandidates[0];
456
- const findPath = path.join(runsDir, latestRunId, 'findings.json');
457
- try {
458
- findings = readJson(findPath);
459
- if (Array.isArray(findings)) {
460
- sourceProvenance.source_type = 'LATEST_RECORDED_RUN';
461
- sourceProvenance.source_identifier = latestRunId;
462
- sourceProvenance.integrity_hash = sha256(fs.readFileSync(findPath));
463
- }
464
- } catch {}
465
- }
383
+ if (err.code === 'NO_FINDINGS') {
384
+ const srcDesc = target ? `live target "${target}"` : (runId ? `run "${runId}"` : 'local workspace');
385
+ throw new NoFindingsError(srcDesc);
466
386
  }
387
+ throw err;
467
388
  }
468
389
 
469
- // 5. If no findings were discovered:
390
+ findings = resolved.findings;
391
+ sourceProvenance = {
392
+ source_type: resolved.source_type,
393
+ source_identifier: resolved.source_identifier,
394
+ findings_count: resolved.findings_count,
395
+ integrity_hash: resolved.integrity_hash,
396
+ };
397
+
398
+ // If no findings were discovered:
470
399
  if (!Array.isArray(findings) || findings.length === 0) {
471
400
  if (isSample) {
472
401
  findings = getSampleBaselineFindings();
473
402
  sourceProvenance.source_type = 'SAMPLE_BASELINE';
474
403
  sourceProvenance.source_identifier = 'citable://samples/enterprise-baseline';
404
+ sourceProvenance.findings_count = findings.length;
475
405
  } else {
476
406
  const srcDesc = target ? `live target "${target}"` : (runId ? `run "${runId}"` : 'local workspace');
477
407
  throw new NoFindingsError(srcDesc);
478
408
  }
479
409
  }
480
410
 
481
- sourceProvenance.findings_count = findings.length;
411
+ if (generationMode === 'CONTRACTUAL') {
412
+ if (!client || typeof client !== 'string' || client.trim() === '') {
413
+ throw new SowError('Contractual SOW requires explicit client name', 'INVALID_COMMERCIAL_TERMS');
414
+ }
415
+ if (budget === null && budgetMinor === null) {
416
+ throw new SowError('Contractual SOW requires explicit budget or budgetMinor', 'INVALID_COMMERCIAL_TERMS');
417
+ }
418
+ if (!termDays || termDays <= 0 || !Number.isFinite(termDays)) {
419
+ throw new SowError('Contractual SOW requires positive finite termDays', 'INVALID_COMMERCIAL_TERMS');
420
+ }
421
+ }
422
+
423
+ let effectiveScopeProps = inScopeProperties.length > 0 ? [...inScopeProperties] : [];
424
+ if (effectiveScopeProps.length === 0) {
425
+ if (baseUrl) {
426
+ effectiveScopeProps.push(baseUrl);
427
+ } else if (target && typeof target === 'string' && target.startsWith('http')) {
428
+ effectiveScopeProps.push(target);
429
+ } else if (isSample) {
430
+ effectiveScopeProps.push('https://example.test');
431
+ } else {
432
+ // Derive from finding subjects
433
+ for (const f of findings) {
434
+ const s = f.subject?.identifier || f.subject?.url || (typeof f.subject === 'string' ? f.subject : null);
435
+ if (s) {
436
+ try {
437
+ const u = new URL(s.includes('://') ? s : `https://${s}`);
438
+ if (!effectiveScopeProps.includes(u.origin)) {
439
+ effectiveScopeProps.push(u.origin);
440
+ }
441
+ } catch {}
442
+ }
443
+ }
444
+ }
445
+ }
482
446
 
483
- const effectiveScopeProps = inScopeProperties.length > 0
484
- ? inScopeProperties
485
- : (baseUrl ? [baseUrl] : (isSample ? ['https://example.test'] : []));
447
+ if (generationMode === 'CONTRACTUAL' && effectiveScopeProps.length === 0) {
448
+ throw new SowError('Contractual SOW requires explicit inScopeProperties or derivable target properties', 'SCOPE_REQUIRED');
449
+ }
486
450
 
487
451
  // -------------------------------------------------------------
488
452
  // THE ADMISSIBILITY GATE (Filter findings into contractual scope)
@@ -495,6 +459,7 @@ export async function generateSow(root, {
495
459
  allowExperimental,
496
460
  excludedDetectors,
497
461
  roleMapping,
462
+ rejectTemplateDefaultOwner: generationMode === 'CONTRACTUAL',
498
463
  });
499
464
 
500
465
  const admitted = gateResult.admitted_requirements;
@@ -536,6 +501,7 @@ export async function generateSow(root, {
536
501
  severity: item.severity,
537
502
  target_subject: item.subject,
538
503
  acceptance_criteria: item.acceptance_test,
504
+ acceptance_basis: item.acceptance_basis || 'automated_detector_rerun',
539
505
  }));
540
506
 
541
507
  // Deliverables definitions
@@ -797,7 +763,7 @@ export async function generateSow(root, {
797
763
  // Machine-readable Provenance Envelope
798
764
  generation_provenance: {
799
765
  generated_at: generatedAt,
800
- generator_version: '1.18.1',
766
+ generator_version: '1.19.0',
801
767
  generation_mode: generationMode,
802
768
  source_type: sourceProvenance.source_type || 'UNKNOWN',
803
769
  source_identifier: sourceProvenance.source_identifier || null,
@@ -997,7 +963,7 @@ export function renderSowHtml(sow) {
997
963
  <html lang="en">
998
964
  <head>
999
965
  <meta charset="UTF-8">
1000
- <title>${sow.title} - ${sow.sow_id}</title>
966
+ <title>${escapeHtml(sow.title)} - ${escapeHtml(sow.sow_id)}</title>
1001
967
  <style>
1002
968
  :root { --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #c9d1d9; --accent: #58a6ff; --danger: #f85149; --warning: #d29922; --success: #3fb950; }
1003
969
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; margin: 0; padding: 40px 20px; }
@@ -1025,14 +991,14 @@ export function renderSowHtml(sow) {
1025
991
  <div class="container">
1026
992
  <header>
1027
993
  <div style="display:flex; justify-content:space-between; align-items:center;">
1028
- <h1>${sow.title}</h1>
994
+ <h1>${escapeHtml(sow.title)}</h1>
1029
995
  <div>
1030
- <span class="sow-badge">${sow.generation_mode}</span>
1031
- <span class="sow-badge" style="background:#1f6feb; color:#fff; margin-left:8px;">${sow.sow_id}</span>
996
+ <span class="sow-badge">${escapeHtml(sow.generation_mode)}</span>
997
+ <span class="sow-badge" style="background:#1f6feb; color:#fff; margin-left:8px;">${escapeHtml(sow.sow_id)}</span>
1032
998
  </div>
1033
999
  </div>
1034
1000
  <div style="color:#8b949e; font-size:14px; margin-top:8px;">
1035
- Client: <strong>${sow.client.name}</strong> &nbsp;|&nbsp; Supplier: <strong>${sow.supplier.name}</strong> &nbsp;|&nbsp; Term: <strong>${sow.term_days} Days</strong> &nbsp;|&nbsp; Effective: <strong>${sow.effective_date}</strong> &nbsp;|&nbsp; Source: <strong>${sow.generation_provenance.source_type}</strong>
1001
+ Client: <strong>${escapeHtml(sow.client.name)}</strong> &nbsp;|&nbsp; Supplier: <strong>${escapeHtml(sow.supplier.name)}</strong> &nbsp;|&nbsp; Term: <strong>${escapeHtml(String(sow.term_days))} Days</strong> &nbsp;|&nbsp; Effective: <strong>${escapeHtml(sow.effective_date)}</strong> &nbsp;|&nbsp; Source: <strong>${escapeHtml(sow.generation_provenance.source_type)}</strong>
1036
1002
  </div>
1037
1003
  </header>
1038
1004
 
@@ -1047,10 +1013,10 @@ export function renderSowHtml(sow) {
1047
1013
 
1048
1014
  <h2 class="section-title">1. Executive Scope Statement</h2>
1049
1015
  <div class="card">
1050
- <p><strong>Objective:</strong> ${sow.executive_scope.business_objective}</p>
1051
- <p><strong>In-Scope Properties:</strong> <code>${sow.executive_scope.in_scope_properties.join(', ')}</code></p>
1052
- <p><strong>In-Scope Systems:</strong> ${sow.executive_scope.in_scope_systems.join('; ')}</p>
1053
- <p><strong>Boundaries:</strong> ${sow.executive_scope.organizational_boundaries}</p>
1016
+ <p><strong>Objective:</strong> ${escapeHtml(sow.executive_scope.business_objective)}</p>
1017
+ <p><strong>In-Scope Properties:</strong> <code>${escapeHtml(sow.executive_scope.in_scope_properties.join(', '))}</code></p>
1018
+ <p><strong>In-Scope Systems:</strong> ${escapeHtml(sow.executive_scope.in_scope_systems.join('; '))}</p>
1019
+ <p><strong>Boundaries:</strong> ${escapeHtml(sow.executive_scope.organizational_boundaries)}</p>
1054
1020
  </div>
1055
1021
 
1056
1022
  <h2 class="section-title">2. Scope Admissibility Gate & Excluded Findings Log</h2>
@@ -1061,11 +1027,11 @@ export function renderSowHtml(sow) {
1061
1027
  <tbody>
1062
1028
  ${sow.admissibility_gate.refusal_log.map((r) => `
1063
1029
  <tr>
1064
- <td><span class="code-ref">${r.finding_id}</span></td>
1065
- <td>${r.subject}</td>
1066
- <td><code>${r.gate_failed}</code></td>
1067
- <td><span class="badge badge-refuse">${r.refusal_code}</span></td>
1068
- <td>${r.refusal_rationale}</td>
1030
+ <td><span class="code-ref">${escapeHtml(r.finding_id)}</span></td>
1031
+ <td>${escapeHtml(r.subject)}</td>
1032
+ <td><code>${escapeHtml(r.gate_failed)}</code></td>
1033
+ <td><span class="badge badge-refuse">${escapeHtml(r.refusal_code)}</span></td>
1034
+ <td>${escapeHtml(r.refusal_rationale)}</td>
1069
1035
  </tr>
1070
1036
  `).join('')}
1071
1037
  </tbody>
@@ -1079,10 +1045,10 @@ export function renderSowHtml(sow) {
1079
1045
  <tbody>
1080
1046
  ${sow.delivery_schedule.milestones.map((m) => `
1081
1047
  <tr>
1082
- <td><strong>${m.milestone_id}</strong></td>
1083
- <td>${m.name}</td>
1084
- <td><span class="code-ref">${m.work_package_id}</span></td>
1085
- <td>${m.billing_trigger}</td>
1048
+ <td><strong>${escapeHtml(m.milestone_id)}</strong></td>
1049
+ <td>${escapeHtml(m.name)}</td>
1050
+ <td><span class="code-ref">${escapeHtml(m.work_package_id)}</span></td>
1051
+ <td>${escapeHtml(m.billing_trigger)}</td>
1086
1052
  <td style="font-weight:bold; color:var(--success);">$${m.fee_usd.toLocaleString('en-US', { minimumFractionDigits: (m.fee_minor % 100 === 0) ? 0 : 2, maximumFractionDigits: 2 })} USD</td>
1087
1053
  </tr>
1088
1054
  `).join('')}
@@ -1098,13 +1064,13 @@ export function renderSowHtml(sow) {
1098
1064
  <tbody>
1099
1065
  ${sow.traceability_matrix.map((t) => `
1100
1066
  <tr>
1101
- <td><span class="code-ref">${t.finding_id}</span></td>
1102
- <td>${t.recommendation}</td>
1103
- <td><strong>${t.sow_requirement_id}</strong></td>
1104
- <td>${t.deliverable_id}</td>
1105
- <td><code>${t.acceptance_test_id}</code></td>
1106
- <td>${t.owner}</td>
1107
- <td><span class="code-ref">${t.evidence_ids?.join(', ') || t.evidence_id}</span></td>
1067
+ <td><span class="code-ref">${escapeHtml(t.finding_id)}</span></td>
1068
+ <td>${escapeHtml(t.recommendation)}</td>
1069
+ <td><strong>${escapeHtml(t.sow_requirement_id)}</strong></td>
1070
+ <td>${escapeHtml(t.deliverable_id)}</td>
1071
+ <td><code>${escapeHtml(t.acceptance_test_id)}</code></td>
1072
+ <td>${escapeHtml(t.owner)}</td>
1073
+ <td><span class="code-ref">${escapeHtml(t.evidence_ids?.join(', ') || t.evidence_id)}</span></td>
1108
1074
  </tr>
1109
1075
  `).join('')}
1110
1076
  </tbody>
@@ -1113,7 +1079,7 @@ export function renderSowHtml(sow) {
1113
1079
  <h2 class="section-title">Signatures & Contractual Authorization</h2>
1114
1080
  <table class="sig-table">
1115
1081
  <thead>
1116
- <tr><th>For Customer: ${sow.client.name}</th><th>For Supplier: ${sow.supplier.name}</th></tr>
1082
+ <tr><th>For Customer: ${escapeHtml(sow.client.name)}</th><th>For Supplier: ${escapeHtml(sow.supplier.name)}</th></tr>
1117
1083
  </thead>
1118
1084
  <tbody>
1119
1085
  <tr>