gemstack-ai 1.3.0 → 2.0.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.
- package/.agents/rules/03-gemstack-security.md +2 -2
- package/.gemstack/state.json +7 -8
- package/CHANGELOG.md +87 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +113 -22
- package/RELEASE_NOTES.md +80 -1
- package/handoff.md +33 -19
- package/package.json +4 -3
- package/scripts/ci/check-package-contents.js +1 -1
- package/scripts/ci/check-secrets.js +84 -0
- package/specs/009-context-capsule/context-capsule.json +4 -4
- package/specs/010-agent-swarm-visual-qa/.gemstack.json +5 -0
- package/specs/010-agent-swarm-visual-qa/closure.json +59 -0
- package/specs/010-agent-swarm-visual-qa/plan.md +759 -0
- package/specs/010-agent-swarm-visual-qa/spec.md +842 -0
- package/specs/010-agent-swarm-visual-qa/swarm.json +49 -0
- package/specs/010-agent-swarm-visual-qa/tasks.md +873 -0
- package/specs/010-agent-swarm-visual-qa/visual-qa.json +41 -0
- package/specs/011-gemstack-2.0-hardening/.gemstack.json +5 -0
- package/specs/011-gemstack-2.0-hardening/closure.json +58 -0
- package/specs/011-gemstack-2.0-hardening/plan.md +210 -0
- package/specs/011-gemstack-2.0-hardening/spec.md +277 -0
- package/specs/011-gemstack-2.0-hardening/tasks.md +59 -0
- package/specs/012-gemstack-2.0-honest-evidence/.gemstack.json +5 -0
- package/specs/012-gemstack-2.0-honest-evidence/closure.json +58 -0
- package/specs/012-gemstack-2.0-honest-evidence/plan.md +202 -0
- package/specs/012-gemstack-2.0-honest-evidence/spec.md +222 -0
- package/specs/012-gemstack-2.0-honest-evidence/tasks.md +99 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/.gemstack.json +9 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/closure.json +58 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/context-capsule.json +227 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/plan.md +179 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/spec.md +212 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/tasks.md +90 -0
- package/specs/014-gemstack-2.0-context-memory/.gemstack.json +9 -0
- package/specs/014-gemstack-2.0-context-memory/closure.json +58 -0
- package/specs/014-gemstack-2.0-context-memory/plan.md +161 -0
- package/specs/014-gemstack-2.0-context-memory/spec.md +163 -0
- package/specs/014-gemstack-2.0-context-memory/tasks.md +79 -0
- package/src/cli.js +11 -0
- package/src/commands/context.js +1 -1
- package/src/commands/doctor.js +18 -0
- package/src/commands/hooks.js +98 -14
- package/src/commands/init.js +1 -1
- package/src/commands/install.js +174 -49
- package/src/commands/spec.js +105 -0
- package/src/commands/swarm.js +111 -0
- package/src/commands/update.js +1 -1
- package/src/commands/verify.js +48 -0
- package/src/commands/visual.js +82 -0
- package/src/lib/backup.js +3 -3
- package/src/lib/closure-context.js +9 -1
- package/src/lib/context-fatigue.js +165 -0
- package/src/lib/contract-amendments.js +109 -0
- package/src/lib/dependency-audit.js +202 -0
- package/src/lib/filesystem-safe.js +85 -15
- package/src/lib/memory-audit.js +121 -0
- package/src/lib/provider-boundary.js +5 -1
- package/src/lib/provider-registry.js +6 -4
- package/src/lib/safety-gates.js +176 -8
- package/src/lib/sdd-rigor.js +181 -0
- package/src/lib/spec-delta.js +194 -0
- package/src/lib/spec-merge.js +168 -0
- package/src/lib/swarm.js +639 -0
- package/src/lib/visual-qa.js +652 -0
- package/template/.agents/rules/03-gemstack-security.md +2 -2
- package/.github/workflows/main-ci.yml +0 -32
- package/.github/workflows/pr-ci.yml +0 -31
- package/.github/workflows/publish.yml +0 -52
- package/.github/workflows/release-readiness.yml +0 -43
- package/gemstack-ai-1.3.0.tgz +0 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemstack Visual QA Validation & Baseline Engine (Upgrade E)
|
|
3
|
+
*
|
|
4
|
+
* Implements deterministic viewport validation, dynamic region masking,
|
|
5
|
+
* cryptographic baseline hashing, and offline visual evidence comparison.
|
|
6
|
+
*
|
|
7
|
+
* ZERO RUNTIME DEPENDENCIES - Node.js built-ins exclusively.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const crypto = require('node:crypto');
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const { normalizePath, hashFile } = require('./hasher');
|
|
14
|
+
const { createFinding } = require('./findings');
|
|
15
|
+
const fssafe = require('./filesystem-safe');
|
|
16
|
+
|
|
17
|
+
const VQA_SCHEMA_VERSION = '1.0.0';
|
|
18
|
+
|
|
19
|
+
const CANONICAL_VIEWPORT_PROFILES = {
|
|
20
|
+
'desktop-standard': { width: 1920, height: 1080, device_scale_factor: 1 },
|
|
21
|
+
'desktop-compact': { width: 1280, height: 800, device_scale_factor: 1 },
|
|
22
|
+
'tablet-portrait': { width: 768, height: 1024, device_scale_factor: 2 },
|
|
23
|
+
'mobile-portrait': { width: 375, height: 667, device_scale_factor: 2 }
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parses and validates visual-qa.json manifest.
|
|
28
|
+
*
|
|
29
|
+
* @param {string|object} input - Raw JSON string or parsed object
|
|
30
|
+
* @returns {object} Canonical parsed visual QA manifest
|
|
31
|
+
*/
|
|
32
|
+
function parseVisualManifest(input) {
|
|
33
|
+
let parsed;
|
|
34
|
+
if (typeof input === 'string') {
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(input);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
const error = new Error(`Invalid JSON in visual-qa manifest: ${err.message}`);
|
|
39
|
+
error.code = 'VQA_PARSE_ERROR';
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
} else if (input && typeof input === 'object') {
|
|
43
|
+
parsed = input;
|
|
44
|
+
} else {
|
|
45
|
+
const error = new Error('Visual manifest input must be a JSON string or object.');
|
|
46
|
+
error.code = 'VQA_INVALID_INPUT';
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return validateVisualSchema(parsed);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validates the schema structure of visual-qa.json manifest.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} manifest
|
|
57
|
+
* @returns {object} Validated manifest
|
|
58
|
+
*/
|
|
59
|
+
function validateVisualSchema(manifest) {
|
|
60
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
61
|
+
const err = new Error('Visual manifest root must be a JSON object.');
|
|
62
|
+
err.code = 'VQA_INVALID_MANIFEST';
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (manifest.version && manifest.version !== VQA_SCHEMA_VERSION) {
|
|
67
|
+
const err = new Error(`Unsupported visual QA schema version: "${manifest.version}". Expected "${VQA_SCHEMA_VERSION}".`);
|
|
68
|
+
err.code = 'VQA_INVALID_MANIFEST';
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!manifest.feature_id || typeof manifest.feature_id !== 'string') {
|
|
73
|
+
const err = new Error('Visual manifest must declare a valid "feature_id".');
|
|
74
|
+
err.code = 'VQA_INVALID_MANIFEST';
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!Array.isArray(manifest.scenarios)) {
|
|
79
|
+
const err = new Error('Visual manifest must declare a "scenarios" array.');
|
|
80
|
+
err.code = 'VQA_INVALID_MANIFEST';
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const seenIds = new Set();
|
|
85
|
+
for (const s of manifest.scenarios) {
|
|
86
|
+
if (!s.scenario_id || typeof s.scenario_id !== 'string') {
|
|
87
|
+
const err = new Error('Every visual QA scenario must declare an explicit "scenario_id".');
|
|
88
|
+
err.code = 'VQA_INVALID_MANIFEST';
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (seenIds.has(s.scenario_id)) {
|
|
93
|
+
const err = new Error(`Duplicate scenario_id detected: "${s.scenario_id}".`);
|
|
94
|
+
err.code = 'VQA_INVALID_MANIFEST';
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
seenIds.add(s.scenario_id);
|
|
98
|
+
|
|
99
|
+
if (!s.route || typeof s.route !== 'string') {
|
|
100
|
+
const err = new Error(`Scenario "${s.scenario_id}" is missing required "route".`);
|
|
101
|
+
err.code = 'VQA_INVALID_MANIFEST';
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!s.viewport || typeof s.viewport !== 'object') {
|
|
106
|
+
const err = new Error(`Scenario "${s.scenario_id}" is missing required "viewport" configuration.`);
|
|
107
|
+
err.code = 'VQA_INVALID_MANIFEST';
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
validateViewport(s.viewport, s.scenario_id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return manifest;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validates deterministic viewport dimensions and profiles.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} viewport - Viewport declaration
|
|
121
|
+
* @param {string} [scenarioId='unknown']
|
|
122
|
+
* @returns {boolean} True if valid; throws if invalid
|
|
123
|
+
*/
|
|
124
|
+
function validateViewport(viewport, scenarioId = 'unknown') {
|
|
125
|
+
if (!viewport || typeof viewport !== 'object') {
|
|
126
|
+
const err = new Error(`Scenario "${scenarioId}": Viewport must be an object.`);
|
|
127
|
+
err.code = 'VQA_INVALID_VIEWPORT';
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const { width, height, device_scale_factor } = viewport;
|
|
132
|
+
|
|
133
|
+
if (typeof width !== 'number' || width <= 0 || !Number.isInteger(width)) {
|
|
134
|
+
const err = new Error(`Scenario "${scenarioId}": Viewport width must be a positive integer.`);
|
|
135
|
+
err.code = 'VQA_INVALID_VIEWPORT';
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (typeof height !== 'number' || height <= 0 || !Number.isInteger(height)) {
|
|
140
|
+
const err = new Error(`Scenario "${scenarioId}": Viewport height must be a positive integer.`);
|
|
141
|
+
err.code = 'VQA_INVALID_VIEWPORT';
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (device_scale_factor !== undefined) {
|
|
146
|
+
if (typeof device_scale_factor !== 'number' || device_scale_factor <= 0) {
|
|
147
|
+
const err = new Error(`Scenario "${scenarioId}": Viewport device_scale_factor must be a positive number.`);
|
|
148
|
+
err.code = 'VQA_INVALID_VIEWPORT';
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Validates capture environment metadata match against scenario expectations.
|
|
158
|
+
*
|
|
159
|
+
* @param {object} scenario - Scenario object
|
|
160
|
+
* @param {object} evidence - Submitted evidence object
|
|
161
|
+
* @returns {{ valid: boolean, findings: Array<object> }}
|
|
162
|
+
*/
|
|
163
|
+
function validateEnvironmentMetadata(scenario, evidence) {
|
|
164
|
+
const findings = [];
|
|
165
|
+
if (!evidence || !evidence.environment) {
|
|
166
|
+
return { valid: true, findings: [] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const expViewport = scenario.viewport || {};
|
|
170
|
+
const actEnv = evidence.environment || {};
|
|
171
|
+
|
|
172
|
+
if (actEnv.color_scheme && expViewport.color_scheme) {
|
|
173
|
+
if (actEnv.color_scheme !== expViewport.color_scheme) {
|
|
174
|
+
findings.push(createFinding({
|
|
175
|
+
code: 'VQA_ENVIRONMENT_MISMATCH',
|
|
176
|
+
contractId: 'deterministic-viewports',
|
|
177
|
+
phase: 'visual-qa',
|
|
178
|
+
location: scenario.scenario_id,
|
|
179
|
+
details: `Color scheme mismatch for scenario "${scenario.scenario_id}": expected "${expViewport.color_scheme}", got "${actEnv.color_scheme}".`
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
valid: findings.length === 0,
|
|
186
|
+
findings
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Applies neutral masking rules to dynamic selectors and sensitive inputs.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} domHtml - Serialized DOM HTML or structure
|
|
194
|
+
* @param {Array<string>} maskSelectors - Custom mask selectors
|
|
195
|
+
* @returns {string} Masked DOM structure
|
|
196
|
+
*/
|
|
197
|
+
function applySelectorMasks(domHtml, maskSelectors = []) {
|
|
198
|
+
if (!domHtml || typeof domHtml !== 'string') return '';
|
|
199
|
+
|
|
200
|
+
let masked = domHtml;
|
|
201
|
+
|
|
202
|
+
// 1. Mandatory secret auto-masking: replace password values with solid mask
|
|
203
|
+
masked = masked.replace(/type=["']password["'][^>]*value=["'][^"']*["']/gi, 'type="password" value="[MASKED_SECRET]"');
|
|
204
|
+
masked = masked.replace(/data-sensitive=["']true["'][^>]*>([^<]*)<\//gi, 'data-sensitive="true">[MASKED_SECRET]</');
|
|
205
|
+
|
|
206
|
+
// 2. Custom selector masking (timestamps, live avatars)
|
|
207
|
+
for (const sel of maskSelectors) {
|
|
208
|
+
const cleanSel = sel.replace(/[.#]/, '');
|
|
209
|
+
const regex = new RegExp(`class=["'][^"']*\\b${cleanSel}\\b[^"']*["'][^>]*>([^<]*)<\\/`, 'gi');
|
|
210
|
+
masked = masked.replace(regex, `class="${cleanSel}">[MASKED_NEUTRAL]</`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return masked;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Validates baseline image integrity and cryptographic hash.
|
|
218
|
+
*
|
|
219
|
+
* @param {object} scenario - Scenario object
|
|
220
|
+
* @param {string} targetDir - Repository target directory
|
|
221
|
+
* @returns {{ valid: boolean, findings: Array<object> }}
|
|
222
|
+
*/
|
|
223
|
+
function validateBaselineIntegrity(scenario, targetDir) {
|
|
224
|
+
const findings = [];
|
|
225
|
+
const baseline = scenario.baseline;
|
|
226
|
+
|
|
227
|
+
if (!baseline) {
|
|
228
|
+
findings.push(createFinding({
|
|
229
|
+
code: 'VQA_BASELINE_MISSING',
|
|
230
|
+
contractId: 'visual-evidence-subordinate',
|
|
231
|
+
phase: 'visual-qa',
|
|
232
|
+
location: scenario.scenario_id,
|
|
233
|
+
details: `Scenario "${scenario.scenario_id}" does not declare a baseline.`
|
|
234
|
+
}));
|
|
235
|
+
return { valid: false, findings };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const imgPath = path.join(targetDir, baseline.image_path);
|
|
239
|
+
if (!fs.existsSync(imgPath)) {
|
|
240
|
+
findings.push(createFinding({
|
|
241
|
+
code: 'VQA_BASELINE_MISSING',
|
|
242
|
+
contractId: 'visual-evidence-subordinate',
|
|
243
|
+
phase: 'visual-qa',
|
|
244
|
+
location: baseline.image_path,
|
|
245
|
+
details: `Baseline image file "${baseline.image_path}" does not exist on disk.`
|
|
246
|
+
}));
|
|
247
|
+
return { valid: false, findings };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const { hashFile } = require('./hasher');
|
|
251
|
+
const liveHash = hashFile(imgPath);
|
|
252
|
+
|
|
253
|
+
if (liveHash !== baseline.image_sha256) {
|
|
254
|
+
findings.push(createFinding({
|
|
255
|
+
code: 'VQA_BASELINE_TAMPERED',
|
|
256
|
+
contractId: 'baseline-explicit-update-only',
|
|
257
|
+
phase: 'visual-qa',
|
|
258
|
+
location: baseline.image_path,
|
|
259
|
+
details: `Baseline image "${baseline.image_path}" was modified on disk without explicit promotion. Recorded: ${baseline.image_sha256.slice(0, 12)}..., Actual: ${liveHash.slice(0, 12)}...`
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
valid: findings.length === 0,
|
|
265
|
+
findings
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Sanitizes and masks sensitive input fields and tokens before visual capture persistence.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} content - HTML or DOM text
|
|
273
|
+
* @returns {string} Sanitized content with sensitive data masked
|
|
274
|
+
*/
|
|
275
|
+
function maskSensitiveFieldsBeforeCapture(content) {
|
|
276
|
+
if (!content || typeof content !== 'string') return content;
|
|
277
|
+
let masked = content;
|
|
278
|
+
|
|
279
|
+
// 1. Password input values
|
|
280
|
+
masked = masked.replace(/(<input\b[^>]*\btype\s*=\s*["']?password["']?[^>]*\bvalue\s*=\s*["'])([^"']*)(["'])/gi, '$1[MASKED_PASSWORD]$3');
|
|
281
|
+
masked = masked.replace(/(<input\b[^>]*\bvalue\s*=\s*["'])([^"']*)(["'][^>]*\btype\s*=\s*["']?password["']?[^>]*>)/gi, '$1[MASKED_PASSWORD]$3');
|
|
282
|
+
|
|
283
|
+
// 2. Sensitive ids or names (password, token, secret, key, credit_card, card)
|
|
284
|
+
masked = masked.replace(/(<input\b[^>]*(?:\bid|\bname)\s*=\s*["']?[^"']*(?:password|token|secret|key|credit_card|card)[^"']*["']?[^>]*\bvalue\s*=\s*["'])([^"']*)(["'])/gi, '$1[MASKED_SENSITIVE]$3');
|
|
285
|
+
masked = masked.replace(/(<input\b[^>]*\bvalue\s*=\s*["'])([^"']*)(["'][^>]*(?:\bid|\bname)\s*=\s*["']?[^"']*(?:password|token|secret|key|credit_card|card)[^"']*["']?[^>]*>)/gi, '$1[MASKED_SENSITIVE]$3');
|
|
286
|
+
|
|
287
|
+
// 3. API Keys and Tokens in attributes or text
|
|
288
|
+
masked = masked.replace(/sk-(?:live_|proj-)?[a-zA-Z0-9_-]{10,}/g, '[MASKED_KEY]');
|
|
289
|
+
|
|
290
|
+
// 4. Credit card numbers (13-19 consecutive digits or grouped with spaces/dashes)
|
|
291
|
+
masked = masked.replace(/\b(?:\d{4}[ -]?){3,4}\d{1,4}\b/g, '[MASKED_CARD]');
|
|
292
|
+
|
|
293
|
+
return masked;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Compares live visual evidence against canonical baseline with honest disk recomputation.
|
|
298
|
+
*
|
|
299
|
+
* @param {object} scenario - Scenario definition
|
|
300
|
+
* @param {object} evidence - Submitted evidence object
|
|
301
|
+
* @param {string} targetDir - Repository target directory
|
|
302
|
+
* @param {object} [options={}] - Additional options (e.g. diffAdapter)
|
|
303
|
+
* @returns {{ status: string, passed: boolean, diff_percentage: number, findings: Array<object> }}
|
|
304
|
+
*/
|
|
305
|
+
function compareVisualEvidence(scenario, evidence, targetDir, options = {}) {
|
|
306
|
+
const findings = [];
|
|
307
|
+
const baseline = scenario.baseline;
|
|
308
|
+
|
|
309
|
+
if (!evidence) {
|
|
310
|
+
findings.push(createFinding({
|
|
311
|
+
code: 'VQA_EVIDENCE_MISSING',
|
|
312
|
+
contractId: 'visual-evidence-subordinate',
|
|
313
|
+
phase: 'visual-qa',
|
|
314
|
+
location: scenario.scenario_id,
|
|
315
|
+
details: `Scenario "${scenario.scenario_id}" has no submitted live evidence.`
|
|
316
|
+
}));
|
|
317
|
+
return { status: 'EVIDENCE_MISSING', passed: false, diff_percentage: 1.0, findings };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!baseline) {
|
|
321
|
+
findings.push(createFinding({
|
|
322
|
+
code: 'VQA_BASELINE_MISSING',
|
|
323
|
+
contractId: 'visual-evidence-subordinate',
|
|
324
|
+
phase: 'visual-qa',
|
|
325
|
+
location: scenario.scenario_id,
|
|
326
|
+
details: `Scenario "${scenario.scenario_id}" has no baseline.`
|
|
327
|
+
}));
|
|
328
|
+
return { status: 'BASELINE_MISSING', passed: false, diff_percentage: 1.0, findings };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let effectiveLiveHash = evidence.image_sha256;
|
|
332
|
+
let effectiveBaselineHash = baseline.image_sha256;
|
|
333
|
+
|
|
334
|
+
// Recompute hashes directly from disk files if targetDir and paths are available
|
|
335
|
+
if (targetDir) {
|
|
336
|
+
if (baseline.image_path) {
|
|
337
|
+
try {
|
|
338
|
+
const absBaselinePath = fssafe.resolveSafeStrict(targetDir, baseline.image_path);
|
|
339
|
+
if (fs.existsSync(absBaselinePath)) {
|
|
340
|
+
const diskBaselineHash = hashFile(absBaselinePath);
|
|
341
|
+
if (baseline.image_sha256 && diskBaselineHash !== baseline.image_sha256) {
|
|
342
|
+
findings.push(createFinding({
|
|
343
|
+
code: 'VQA_BASELINE_TAMPERED',
|
|
344
|
+
contractId: 'baseline-explicit-update-only',
|
|
345
|
+
phase: 'visual-qa',
|
|
346
|
+
location: baseline.image_path,
|
|
347
|
+
details: `Baseline image "${baseline.image_path}" was modified on disk. Recorded: ${baseline.image_sha256}, Actual: ${diskBaselineHash}.`
|
|
348
|
+
}));
|
|
349
|
+
return { status: 'BASELINE_TAMPERED', passed: false, diff_percentage: 1.0, findings };
|
|
350
|
+
}
|
|
351
|
+
effectiveBaselineHash = diskBaselineHash;
|
|
352
|
+
}
|
|
353
|
+
} catch (err) {
|
|
354
|
+
findings.push(createFinding({
|
|
355
|
+
code: 'VQA_BASELINE_TAMPERED',
|
|
356
|
+
contractId: 'baseline-explicit-update-only',
|
|
357
|
+
phase: 'visual-qa',
|
|
358
|
+
location: baseline.image_path,
|
|
359
|
+
details: `Error validating baseline path: ${err.message}`
|
|
360
|
+
}));
|
|
361
|
+
return { status: 'BASELINE_TAMPERED', passed: false, diff_percentage: 1.0, findings };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const livePathCandidate = evidence.live_screenshot_path || (evidence.image_path && fs.existsSync(fssafe.resolveSafe(targetDir, evidence.image_path)) ? evidence.image_path : null);
|
|
366
|
+
if (livePathCandidate) {
|
|
367
|
+
try {
|
|
368
|
+
const absLivePath = fssafe.resolveSafeStrict(targetDir, livePathCandidate);
|
|
369
|
+
if (!fs.existsSync(absLivePath)) {
|
|
370
|
+
findings.push(createFinding({
|
|
371
|
+
code: 'VQA_IMAGE_NOT_FOUND',
|
|
372
|
+
contractId: 'visual-evidence-subordinate',
|
|
373
|
+
phase: 'visual-qa',
|
|
374
|
+
location: scenario.scenario_id,
|
|
375
|
+
details: `Live screenshot file not found: ${livePathCandidate}`
|
|
376
|
+
}));
|
|
377
|
+
return { status: 'EVIDENCE_MISSING', passed: false, diff_percentage: 1.0, findings };
|
|
378
|
+
}
|
|
379
|
+
const diskLiveHash = hashFile(absLivePath);
|
|
380
|
+
if (evidence.image_sha256 && evidence.image_sha256 !== diskLiveHash) {
|
|
381
|
+
findings.push(createFinding({
|
|
382
|
+
code: 'VQA_EVIDENCE_HASH_MISMATCH',
|
|
383
|
+
contractId: 'visual-evidence-subordinate',
|
|
384
|
+
phase: 'visual-qa',
|
|
385
|
+
location: scenario.scenario_id,
|
|
386
|
+
details: `Submitted evidence image_sha256 (${evidence.image_sha256}) does not match disk file hash (${diskLiveHash}).`
|
|
387
|
+
}));
|
|
388
|
+
return { status: 'EVIDENCE_HASH_MISMATCH', passed: false, diff_percentage: 1.0, findings };
|
|
389
|
+
}
|
|
390
|
+
effectiveLiveHash = diskLiveHash;
|
|
391
|
+
} catch (err) {
|
|
392
|
+
findings.push(createFinding({
|
|
393
|
+
code: 'VQA_IMAGE_NOT_FOUND',
|
|
394
|
+
contractId: 'visual-evidence-subordinate',
|
|
395
|
+
phase: 'visual-qa',
|
|
396
|
+
location: scenario.scenario_id,
|
|
397
|
+
details: `Error validating live screenshot path: ${err.message}`
|
|
398
|
+
}));
|
|
399
|
+
return { status: 'EVIDENCE_MISSING', passed: false, diff_percentage: 1.0, findings };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Fast-path: SHA-256 match from verified disk hashes
|
|
405
|
+
if (effectiveLiveHash && effectiveBaselineHash && effectiveLiveHash === effectiveBaselineHash) {
|
|
406
|
+
return {
|
|
407
|
+
status: 'PASS',
|
|
408
|
+
passed: true,
|
|
409
|
+
diff_percentage: 0.0,
|
|
410
|
+
findings: []
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Evaluate tolerances and real diff adapter
|
|
415
|
+
const maxDiff = (scenario.tolerances && scenario.tolerances.max_diff_percentage !== undefined)
|
|
416
|
+
? scenario.tolerances.max_diff_percentage
|
|
417
|
+
: 0.00;
|
|
418
|
+
|
|
419
|
+
const diffAdapter = (options && options.diffAdapter) || (evidence && evidence.diffAdapter);
|
|
420
|
+
|
|
421
|
+
if (diffAdapter && typeof diffAdapter.computeDiff === 'function') {
|
|
422
|
+
let baselineBuf = null;
|
|
423
|
+
let liveBuf = null;
|
|
424
|
+
if (targetDir && baseline.image_path) {
|
|
425
|
+
try { baselineBuf = fs.readFileSync(fssafe.resolveSafeStrict(targetDir, baseline.image_path)); } catch {}
|
|
426
|
+
}
|
|
427
|
+
const liveRel = evidence.live_screenshot_path || evidence.image_path;
|
|
428
|
+
if (targetDir && liveRel) {
|
|
429
|
+
try { liveBuf = fs.readFileSync(fssafe.resolveSafeStrict(targetDir, liveRel)); } catch {}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const diffResult = diffAdapter.computeDiff(baselineBuf, liveBuf, scenario.tolerances);
|
|
433
|
+
const observedDiff = typeof diffResult.diff_percentage === 'number' ? diffResult.diff_percentage : 0.0;
|
|
434
|
+
|
|
435
|
+
if (observedDiff > maxDiff) {
|
|
436
|
+
findings.push(createFinding({
|
|
437
|
+
code: 'VQA_VISUAL_REGRESSION',
|
|
438
|
+
contractId: 'visual-evidence-subordinate',
|
|
439
|
+
phase: 'visual-qa',
|
|
440
|
+
location: scenario.scenario_id,
|
|
441
|
+
details: `Visual regression on "${scenario.scenario_id}": observed diff ${observedDiff} exceeds maximum allowed ${maxDiff}.`
|
|
442
|
+
}));
|
|
443
|
+
return {
|
|
444
|
+
status: 'VISUAL_REGRESSION',
|
|
445
|
+
passed: false,
|
|
446
|
+
diff_percentage: observedDiff,
|
|
447
|
+
findings
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
status: 'PASS',
|
|
453
|
+
passed: true,
|
|
454
|
+
diff_percentage: observedDiff,
|
|
455
|
+
findings: []
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// If no diff adapter is provided:
|
|
460
|
+
const observedDiff = typeof evidence.diff_percentage === 'number' ? evidence.diff_percentage : 0.05;
|
|
461
|
+
|
|
462
|
+
if (observedDiff > maxDiff) {
|
|
463
|
+
findings.push(createFinding({
|
|
464
|
+
code: 'VQA_VISUAL_REGRESSION',
|
|
465
|
+
contractId: 'visual-evidence-subordinate',
|
|
466
|
+
phase: 'visual-qa',
|
|
467
|
+
location: scenario.scenario_id,
|
|
468
|
+
details: `Visual regression on "${scenario.scenario_id}": observed diff ${observedDiff} exceeds maximum allowed ${maxDiff}.`
|
|
469
|
+
}));
|
|
470
|
+
return {
|
|
471
|
+
status: 'VISUAL_REGRESSION',
|
|
472
|
+
passed: false,
|
|
473
|
+
diff_percentage: observedDiff,
|
|
474
|
+
findings
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// When live screenshot differs from baseline and caller claims low diff without adapter: FAIL-CLOSED UNVERIFIED
|
|
479
|
+
findings.push(createFinding({
|
|
480
|
+
code: 'VQA_DIFF_ENGINE_UNAVAILABLE',
|
|
481
|
+
contractId: 'visual-evidence-subordinate',
|
|
482
|
+
phase: 'visual-qa',
|
|
483
|
+
location: scenario.scenario_id,
|
|
484
|
+
details: `Visual deviation detected on "${scenario.scenario_id}" but no diff engine adapter is available. Status UNVERIFIED.`
|
|
485
|
+
}));
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
status: 'UNVERIFIED',
|
|
489
|
+
passed: false,
|
|
490
|
+
diff_percentage: observedDiff,
|
|
491
|
+
findings
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Promotes a live evidence capture to canonical baseline status.
|
|
497
|
+
*
|
|
498
|
+
* @param {string} targetDir - Repository target directory
|
|
499
|
+
* @param {string} featureId - Active feature directory path
|
|
500
|
+
* @param {string} scenarioId - Target scenario ID
|
|
501
|
+
* @param {string} liveImagePath - Relative path to captured screenshot
|
|
502
|
+
* @param {string} approvedBy - Approver role/identity
|
|
503
|
+
* @returns {{ success: boolean, updatedManifest: object }}
|
|
504
|
+
*/
|
|
505
|
+
function promoteVisualBaseline(targetDir, featureId, scenarioId, liveImagePath, approvedBy = 'human-lead') {
|
|
506
|
+
const manifestPath = path.join(targetDir, featureId, 'visual-qa.json');
|
|
507
|
+
if (!fs.existsSync(manifestPath)) {
|
|
508
|
+
const err = new Error(`visual-qa.json not found in "${featureId}".`);
|
|
509
|
+
err.code = 'VQA_MANIFEST_NOT_FOUND';
|
|
510
|
+
throw err;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const raw = fs.readFileSync(manifestPath, 'utf8');
|
|
514
|
+
const manifest = parseVisualManifest(raw);
|
|
515
|
+
|
|
516
|
+
const scenario = (manifest.scenarios || []).find(s => s.scenario_id === scenarioId);
|
|
517
|
+
if (!scenario) {
|
|
518
|
+
const err = new Error(`Scenario "${scenarioId}" not found in manifest.`);
|
|
519
|
+
err.code = 'VQA_SCENARIO_NOT_FOUND';
|
|
520
|
+
throw err;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const absLivePath = path.join(targetDir, liveImagePath);
|
|
524
|
+
if (!fs.existsSync(absLivePath)) {
|
|
525
|
+
const err = new Error(`Live image file "${liveImagePath}" not found.`);
|
|
526
|
+
err.code = 'VQA_IMAGE_NOT_FOUND';
|
|
527
|
+
throw err;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const { hashFile } = require('./hasher');
|
|
531
|
+
const imgHash = hashFile(absLivePath);
|
|
532
|
+
|
|
533
|
+
// Copy to canonical baselines directory
|
|
534
|
+
const baselinesDir = path.join(targetDir, featureId, 'baselines');
|
|
535
|
+
if (!fs.existsSync(baselinesDir)) {
|
|
536
|
+
fs.mkdirSync(baselinesDir, { recursive: true });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const canonicalRelPath = path.posix.join(featureId.replace(/\\/g, '/'), 'baselines', `${scenarioId.toLowerCase()}.png`);
|
|
540
|
+
const canonicalAbsPath = path.join(targetDir, canonicalRelPath);
|
|
541
|
+
fs.copyFileSync(absLivePath, canonicalAbsPath);
|
|
542
|
+
|
|
543
|
+
scenario.baseline = {
|
|
544
|
+
image_path: canonicalRelPath,
|
|
545
|
+
image_sha256: imgHash,
|
|
546
|
+
dom_hash: scenario.baseline ? scenario.baseline.dom_hash : 'd0m_h4sh_pr0m0t3d',
|
|
547
|
+
approved_by: approvedBy,
|
|
548
|
+
approved_at: new Date().toISOString()
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
success: true,
|
|
555
|
+
updatedManifest: manifest
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Validates a complete visual-qa.json file in read-only mode.
|
|
561
|
+
*
|
|
562
|
+
* @param {string} targetDir - Repository target directory
|
|
563
|
+
* @param {string} featureId - Active feature directory path
|
|
564
|
+
* @returns {{ valid: boolean, state: string, findings: Array<object> }}
|
|
565
|
+
*/
|
|
566
|
+
function validateVisualManifest(targetDir, featureId) {
|
|
567
|
+
const manifestPath = path.join(targetDir, featureId, 'visual-qa.json');
|
|
568
|
+
if (!fs.existsSync(manifestPath)) {
|
|
569
|
+
return {
|
|
570
|
+
valid: false,
|
|
571
|
+
state: 'MISSING',
|
|
572
|
+
findings: []
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
let raw;
|
|
577
|
+
try {
|
|
578
|
+
raw = fs.readFileSync(manifestPath, 'utf8');
|
|
579
|
+
} catch (err) {
|
|
580
|
+
return {
|
|
581
|
+
valid: false,
|
|
582
|
+
state: 'UNREADABLE',
|
|
583
|
+
findings: [createFinding({
|
|
584
|
+
code: 'VQA_UNREADABLE',
|
|
585
|
+
contractId: 'visual-evidence-subordinate',
|
|
586
|
+
phase: 'visual-qa',
|
|
587
|
+
location: manifestPath,
|
|
588
|
+
details: err.message
|
|
589
|
+
})]
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
let manifest;
|
|
594
|
+
try {
|
|
595
|
+
manifest = parseVisualManifest(raw);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
return {
|
|
598
|
+
valid: false,
|
|
599
|
+
state: 'INVALID',
|
|
600
|
+
findings: [createFinding({
|
|
601
|
+
code: 'VQA_INVALID_MANIFEST',
|
|
602
|
+
contractId: 'visual-evidence-subordinate',
|
|
603
|
+
phase: 'visual-qa',
|
|
604
|
+
location: manifestPath,
|
|
605
|
+
details: err.message
|
|
606
|
+
})]
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const findings = [];
|
|
611
|
+
const scenarios = manifest.scenarios || [];
|
|
612
|
+
|
|
613
|
+
for (const s of scenarios) {
|
|
614
|
+
// 1. Viewport check
|
|
615
|
+
try {
|
|
616
|
+
validateViewport(s.viewport, s.scenario_id);
|
|
617
|
+
} catch (vErr) {
|
|
618
|
+
findings.push(createFinding({
|
|
619
|
+
code: 'VQA_INVALID_VIEWPORT',
|
|
620
|
+
contractId: 'deterministic-viewports',
|
|
621
|
+
phase: 'visual-qa',
|
|
622
|
+
location: s.scenario_id,
|
|
623
|
+
details: vErr.message
|
|
624
|
+
}));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// 2. Baseline integrity check
|
|
628
|
+
const baseCheck = validateBaselineIntegrity(s, targetDir);
|
|
629
|
+
findings.push(...baseCheck.findings);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return {
|
|
633
|
+
valid: findings.length === 0,
|
|
634
|
+
state: findings.length === 0 ? 'VALID' : 'INVALID',
|
|
635
|
+
findings
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
module.exports = {
|
|
640
|
+
VQA_SCHEMA_VERSION,
|
|
641
|
+
CANONICAL_VIEWPORT_PROFILES,
|
|
642
|
+
parseVisualManifest,
|
|
643
|
+
validateVisualSchema,
|
|
644
|
+
validateViewport,
|
|
645
|
+
validateEnvironmentMetadata,
|
|
646
|
+
applySelectorMasks,
|
|
647
|
+
maskSensitiveFieldsBeforeCapture,
|
|
648
|
+
validateBaselineIntegrity,
|
|
649
|
+
compareVisualEvidence,
|
|
650
|
+
promoteVisualBaseline,
|
|
651
|
+
validateVisualManifest
|
|
652
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
# Gemstack Security Core (
|
|
1
|
+
# Gemstack Security Core (Architecture & Security Gates)
|
|
2
2
|
|
|
3
3
|
## Propósito
|
|
4
|
-
Esta es la Ley de "Seguridad por Diseño". Todo código escrito, planificado o revisado por la IA bajo el marco Gemstack DEBE adherirse a estos principios de blindaje, independientemente del stack tecnológico utilizado. El objetivo es
|
|
4
|
+
Esta es la Ley de "Seguridad por Diseño". Todo código escrito, planificado o revisado por la IA bajo el marco Gemstack DEBE adherirse a estos principios de blindaje, independientemente del stack tecnológico utilizado. El objetivo es establecer controles sistemáticos y verificables frente a vulnerabilidades comunes (OWASP Top 10) desde el momento de la concepción del código.
|
|
5
5
|
|
|
6
6
|
## 1. Cero Exposición de Credenciales (Zero Trust Secrets)
|
|
7
7
|
- **Regla Estricta:** JAMÁS hardcodees contraseñas, llaves de API, secrets de Webhooks o URIs de bases de datos en el código fuente.
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
name: Main CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches:
|
|
6
|
-
- main
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
test:
|
|
10
|
-
name: Full CI
|
|
11
|
-
runs-on: ${{ matrix.os }}
|
|
12
|
-
strategy:
|
|
13
|
-
matrix:
|
|
14
|
-
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
15
|
-
node-version: [18.x, 20.x]
|
|
16
|
-
steps:
|
|
17
|
-
- name: Checkout repository
|
|
18
|
-
uses: actions/checkout@v4
|
|
19
|
-
- name: Setup Node.js
|
|
20
|
-
uses: actions/setup-node@v4
|
|
21
|
-
with:
|
|
22
|
-
node-version: ${{ matrix.node-version }}
|
|
23
|
-
- name: Install Dependencies
|
|
24
|
-
run: npm install
|
|
25
|
-
- name: Run Native Tests
|
|
26
|
-
run: npm test
|
|
27
|
-
- name: Run CI Suite
|
|
28
|
-
run: npm run ci:all
|
|
29
|
-
- name: Run Demo Smoke
|
|
30
|
-
run: npm run ci:demo
|
|
31
|
-
- name: Validate Package Build
|
|
32
|
-
run: npm run pack:dry
|