gemstack-ai 1.2.0 → 1.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 (37) hide show
  1. package/.gemstack/state.json +11 -10
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +89 -9
  4. package/RELEASE_NOTES.md +77 -0
  5. package/{gemstack-ai-1.2.0.tgz → gemstack-ai-1.4.0.tgz} +0 -0
  6. package/handoff.md +14 -12
  7. package/package.json +2 -2
  8. package/specs/008-cost-provider-safety-gates/.gemstack.json +5 -0
  9. package/specs/008-cost-provider-safety-gates/closure.json +59 -0
  10. package/specs/008-cost-provider-safety-gates/plan.md +456 -0
  11. package/specs/008-cost-provider-safety-gates/spec.md +633 -0
  12. package/specs/008-cost-provider-safety-gates/tasks.md +635 -0
  13. package/specs/009-context-capsule/closure.json +59 -0
  14. package/specs/009-context-capsule/context-capsule.json +428 -0
  15. package/specs/009-context-capsule/plan.md +663 -0
  16. package/specs/009-context-capsule/spec.md +913 -0
  17. package/specs/009-context-capsule/tasks.md +720 -0
  18. package/specs/010-agent-swarm-visual-qa/.gemstack.json +5 -0
  19. package/specs/010-agent-swarm-visual-qa/closure.json +59 -0
  20. package/specs/010-agent-swarm-visual-qa/plan.md +759 -0
  21. package/specs/010-agent-swarm-visual-qa/spec.md +842 -0
  22. package/specs/010-agent-swarm-visual-qa/swarm.json +49 -0
  23. package/specs/010-agent-swarm-visual-qa/tasks.md +873 -0
  24. package/specs/010-agent-swarm-visual-qa/visual-qa.json +41 -0
  25. package/src/cli.js +10 -0
  26. package/src/commands/context.js +95 -0
  27. package/src/commands/swarm.js +111 -0
  28. package/src/commands/verify.js +92 -0
  29. package/src/commands/visual.js +82 -0
  30. package/src/lib/closure-context.js +18 -1
  31. package/src/lib/context-capsule.js +594 -0
  32. package/src/lib/cost-ledger.js +355 -0
  33. package/src/lib/provider-boundary.js +186 -0
  34. package/src/lib/provider-registry.js +265 -0
  35. package/src/lib/safety-gates.js +277 -0
  36. package/src/lib/swarm.js +639 -0
  37. package/src/lib/visual-qa.js +499 -0
@@ -0,0 +1,355 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { createFinding } = require('./findings');
4
+
5
+ const VALID_COST_STATES = ['FREE', 'BILLABLE', 'POTENTIALLY_BILLABLE', 'UNKNOWN'];
6
+ const VALID_PROVIDER_TYPES = ['COMMERCIAL', 'LOCAL', 'MOCK'];
7
+
8
+ const FORBIDDEN_KEY_NAMES = new Set([
9
+ 'apikey',
10
+ 'api_key',
11
+ 'token',
12
+ 'accesstoken',
13
+ 'access_token',
14
+ 'bearertoken',
15
+ 'bearer_token',
16
+ 'secret',
17
+ 'clientsecret',
18
+ 'client_secret',
19
+ 'authorization',
20
+ 'private_key',
21
+ 'privatekey',
22
+ 'passwd',
23
+ 'password',
24
+ 'credentials'
25
+ ]);
26
+
27
+ const SECRET_VALUE_PATTERNS = [
28
+ /sk-[a-zA-Z0-9_-]{16,}/,
29
+ /ghp_[a-zA-Z0-9]{20,}/,
30
+ /Bearer\s+[a-zA-Z0-9._-]{16,}/i,
31
+ /AIza[0-9A-Za-z-_]{30,}/,
32
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/
33
+ ];
34
+
35
+ /**
36
+ * Scans an arbitrary JSON-compatible data structure for forbidden credential keys and secret patterns.
37
+ *
38
+ * @param {*} data
39
+ * @param {string} [currentPath='']
40
+ * @returns {{ valid: boolean, violations: Array<{ path: string, reason: string }> }}
41
+ */
42
+ function auditSecretsForbidden(data, currentPath = '') {
43
+ const violations = [];
44
+
45
+ function walk(node, p) {
46
+ if (!node || typeof node !== 'object') {
47
+ if (typeof node === 'string') {
48
+ for (const pattern of SECRET_VALUE_PATTERNS) {
49
+ if (pattern.test(node)) {
50
+ violations.push({
51
+ path: p,
52
+ reason: 'Forbidden secret pattern detected matching ' + pattern.toString()
53
+ });
54
+ break;
55
+ }
56
+ }
57
+ }
58
+ return;
59
+ }
60
+
61
+ if (Array.isArray(node)) {
62
+ for (let i = 0; i < node.length; i++) {
63
+ walk(node[i], p + '[' + i + ']');
64
+ }
65
+ return;
66
+ }
67
+
68
+ for (const key of Object.keys(node)) {
69
+ const normalizedKey = key.toLowerCase().replace(/[-_]/g, '');
70
+ const rawNormalized = key.toLowerCase();
71
+ const childPath = p ? p + '.' + key : key;
72
+
73
+ if (FORBIDDEN_KEY_NAMES.has(rawNormalized) || FORBIDDEN_KEY_NAMES.has(normalizedKey)) {
74
+ violations.push({
75
+ path: childPath,
76
+ reason: 'Forbidden credential property name detected: "' + key + '"'
77
+ });
78
+ }
79
+
80
+ walk(node[key], childPath);
81
+ }
82
+ }
83
+
84
+ walk(data, currentPath);
85
+
86
+ return {
87
+ valid: violations.length === 0,
88
+ violations
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Validates the structure and constraints of a cost-ledger data object.
94
+ *
95
+ * @param {*} data
96
+ * @returns {{ valid: boolean, errors: string[] }}
97
+ */
98
+ function validateLedgerSchema(data) {
99
+ const errors = [];
100
+
101
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
102
+ return { valid: false, errors: ['Cost ledger root must be a JSON object'] };
103
+ }
104
+
105
+ if (data.version !== 1) {
106
+ errors.push('Cost ledger version must be 1, got ' + JSON.stringify(data.version));
107
+ }
108
+
109
+ if (typeof data.currency !== 'string' || !data.currency.trim()) {
110
+ errors.push('Cost ledger requires non-empty string field "currency"');
111
+ }
112
+
113
+ if (!data.providers || typeof data.providers !== 'object' || Array.isArray(data.providers)) {
114
+ errors.push('Cost ledger requires "providers" map');
115
+ return { valid: false, errors };
116
+ }
117
+
118
+ const providerKeys = Object.keys(data.providers);
119
+ for (const providerId of providerKeys) {
120
+ const provider = data.providers[providerId];
121
+ if (!provider || typeof provider !== 'object' || Array.isArray(provider)) {
122
+ errors.push('Provider "' + providerId + '" must be an object');
123
+ continue;
124
+ }
125
+
126
+ if (!VALID_PROVIDER_TYPES.includes(provider.type)) {
127
+ errors.push('Provider "' + providerId + '" has invalid type "' + provider.type + '". Must be one of: ' + VALID_PROVIDER_TYPES.join(', '));
128
+ }
129
+
130
+ if (typeof provider.pricing_model !== 'string' || !provider.pricing_model.trim()) {
131
+ errors.push('Provider "' + providerId + '" requires string field "pricing_model"');
132
+ }
133
+
134
+ if (!provider.capabilities || typeof provider.capabilities !== 'object' || Array.isArray(provider.capabilities)) {
135
+ errors.push('Provider "' + providerId + '" requires "capabilities" map');
136
+ continue;
137
+ }
138
+
139
+ for (const capId of Object.keys(provider.capabilities)) {
140
+ const cap = provider.capabilities[capId];
141
+ if (!cap || typeof cap !== 'object' || Array.isArray(cap)) {
142
+ errors.push('Provider "' + providerId + '" capability "' + capId + '" must be an object');
143
+ continue;
144
+ }
145
+
146
+ if (!VALID_COST_STATES.includes(cap.cost_state)) {
147
+ errors.push('Capability "' + providerId + '.' + capId + '" has invalid cost_state "' + cap.cost_state + '". Must be one of: ' + VALID_COST_STATES.join(', '));
148
+ }
149
+
150
+ if (typeof cap.estimated_unit_cost !== 'number' || Number.isNaN(cap.estimated_unit_cost) || cap.estimated_unit_cost < 0) {
151
+ errors.push('Capability "' + providerId + '.' + capId + '" requires non-negative number "estimated_unit_cost"');
152
+ }
153
+
154
+ if (cap.freshness_date !== undefined) {
155
+ const parsedDate = Date.parse(cap.freshness_date);
156
+ if (Number.isNaN(parsedDate)) {
157
+ errors.push('Capability "' + providerId + '.' + capId + '" has invalid freshness_date: "' + cap.freshness_date + '"');
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ return {
164
+ valid: errors.length === 0,
165
+ errors
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Checks if a capability entry freshness date is older than maxAgeDays.
171
+ *
172
+ * @param {object} capabilityEntry
173
+ * @param {number} [maxAgeDays=90]
174
+ * @param {Date} [referenceDate=new Date()]
175
+ * @returns {{ isStale: boolean, ageDays: number|null, message: string|null }}
176
+ */
177
+ function checkStaleness(capabilityEntry, maxAgeDays = 90, referenceDate = new Date()) {
178
+ if (!capabilityEntry || !capabilityEntry.freshness_date) {
179
+ return {
180
+ isStale: true,
181
+ ageDays: null,
182
+ message: 'Capability entry missing required freshness_date'
183
+ };
184
+ }
185
+
186
+ const freshnessTime = Date.parse(capabilityEntry.freshness_date);
187
+ if (Number.isNaN(freshnessTime)) {
188
+ return {
189
+ isStale: true,
190
+ ageDays: null,
191
+ message: 'Invalid freshness_date: "' + capabilityEntry.freshness_date + '"'
192
+ };
193
+ }
194
+
195
+ const refTime = referenceDate.getTime();
196
+ const diffMs = refTime - freshnessTime;
197
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
198
+
199
+ if (diffDays > maxAgeDays) {
200
+ return {
201
+ isStale: true,
202
+ ageDays: diffDays,
203
+ message: 'Pricing assumption freshness date "' + capabilityEntry.freshness_date + '" is ' + diffDays + ' days old (exceeds ' + maxAgeDays + ' days limit)'
204
+ };
205
+ }
206
+
207
+ return {
208
+ isStale: false,
209
+ ageDays: diffDays,
210
+ message: null
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Serializes cost ledger object with UTF-16 code-unit sorted keys deterministically.
216
+ *
217
+ * @param {object} ledger
218
+ * @returns {string} Formatted JSON string
219
+ */
220
+ function serializeCostLedger(ledger) {
221
+ function sortKeys(obj) {
222
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj;
223
+ const sorted = {};
224
+ const keys = Object.keys(obj).sort((a, b) => (a < b ? -1 : (a > b ? 1 : 0)));
225
+ for (const k of keys) {
226
+ sorted[k] = sortKeys(obj[k]);
227
+ }
228
+ return sorted;
229
+ }
230
+
231
+ return JSON.stringify(sortKeys(ledger), null, 2) + '\n';
232
+ }
233
+
234
+ /**
235
+ * Loads, parses, and validates cost-ledger.json from disk.
236
+ *
237
+ * @param {string} filePath
238
+ * @param {object} [options={}]
239
+ * @param {Date} [options.referenceDate=new Date()]
240
+ * @param {number} [options.maxAgeDays=90]
241
+ * @returns {{ ledger: object|null, findings: object[], valid: boolean }}
242
+ */
243
+ function loadCostLedger(filePath, options = {}) {
244
+ const { referenceDate = new Date(), maxAgeDays = 90 } = options;
245
+ const findings = [];
246
+
247
+ if (!fs.existsSync(filePath)) {
248
+ findings.push(createFinding({
249
+ code: 'COST_LEDGER_INVALID',
250
+ contractId: 'upgrade-c-cost-states',
251
+ phase: 'IMPLEMENTATION',
252
+ location: filePath,
253
+ details: 'Cost ledger file not found at: ' + filePath
254
+ }));
255
+ return { ledger: null, findings, valid: false };
256
+ }
257
+
258
+ let raw;
259
+ try {
260
+ raw = fs.readFileSync(filePath, 'utf8');
261
+ } catch (err) {
262
+ findings.push(createFinding({
263
+ code: 'COST_LEDGER_INVALID',
264
+ contractId: 'upgrade-c-cost-states',
265
+ phase: 'IMPLEMENTATION',
266
+ location: filePath,
267
+ details: 'Failed to read cost ledger: ' + err.message
268
+ }));
269
+ return { ledger: null, findings, valid: false };
270
+ }
271
+
272
+ let data;
273
+ try {
274
+ data = JSON.parse(raw);
275
+ } catch (err) {
276
+ findings.push(createFinding({
277
+ code: 'COST_LEDGER_INVALID',
278
+ contractId: 'upgrade-c-cost-states',
279
+ phase: 'IMPLEMENTATION',
280
+ location: filePath,
281
+ details: 'Cost ledger malformed JSON syntax: ' + err.message
282
+ }));
283
+ return { ledger: null, findings, valid: false };
284
+ }
285
+
286
+ // 1. Audit Secrets (Strict Fail-Closed)
287
+ const secretsAudit = auditSecretsForbidden(data);
288
+ if (!secretsAudit.valid) {
289
+ for (const v of secretsAudit.violations) {
290
+ findings.push(createFinding({
291
+ code: 'COST_LEDGER_INVALID',
292
+ contractId: 'secrets-forbidden-in-safety-artifacts',
293
+ phase: 'IMPLEMENTATION',
294
+ location: filePath,
295
+ details: 'Secret violation at ' + v.path + ': ' + v.reason
296
+ }));
297
+ }
298
+ return { ledger: null, findings, valid: false };
299
+ }
300
+
301
+ // 2. Validate Schema
302
+ const schemaAudit = validateLedgerSchema(data);
303
+ if (!schemaAudit.valid) {
304
+ for (const err of schemaAudit.errors) {
305
+ findings.push(createFinding({
306
+ code: 'COST_LEDGER_INVALID',
307
+ contractId: 'upgrade-c-cost-states',
308
+ phase: 'IMPLEMENTATION',
309
+ location: filePath,
310
+ details: err
311
+ }));
312
+ }
313
+ return { ledger: null, findings, valid: false };
314
+ }
315
+
316
+ // 3. Staleness Evaluation (Warnings)
317
+ if (data.providers) {
318
+ for (const [providerId, provider] of Object.entries(data.providers)) {
319
+ if (provider.capabilities) {
320
+ for (const [capId, cap] of Object.entries(provider.capabilities)) {
321
+ const staleness = checkStaleness(cap, maxAgeDays, referenceDate);
322
+ if (staleness.isStale) {
323
+ const finding = createFinding({
324
+ code: 'STALE_PROVIDER_COST_ASSUMPTION',
325
+ contractId: 'upgrade-c-cost-states',
326
+ phase: 'IMPLEMENTATION',
327
+ location: filePath,
328
+ details: '[' + providerId + '.' + capId + '] ' + staleness.message
329
+ });
330
+ finding.is_blocking = false; // WARNING severity
331
+ findings.push(finding);
332
+ }
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ const hasBlockers = findings.some(f => f.is_blocking);
339
+ return {
340
+ ledger: hasBlockers ? null : data,
341
+ findings,
342
+ valid: !hasBlockers
343
+ };
344
+ }
345
+
346
+ module.exports = {
347
+ VALID_COST_STATES,
348
+ VALID_PROVIDER_TYPES,
349
+ FORBIDDEN_KEY_NAMES,
350
+ auditSecretsForbidden,
351
+ validateLedgerSchema,
352
+ checkStaleness,
353
+ serializeCostLedger,
354
+ loadCostLedger
355
+ };
@@ -0,0 +1,186 @@
1
+ const crypto = require('node:crypto');
2
+ const { PROVIDER_ID_REGEX } = require('./provider-registry');
3
+ const { evaluateProviderCapability, evaluateBillableAction } = require('./safety-gates');
4
+
5
+ /**
6
+ * Normalizes provider identity and prevents alias spoofing or path traversal.
7
+ *
8
+ * @param {string} rawId
9
+ * @returns {string} Normalized provider slug
10
+ */
11
+ function normalizeProviderId(rawId) {
12
+ if (typeof rawId !== 'string') {
13
+ throw new Error('Provider ID must be a string.');
14
+ }
15
+
16
+ const trimmed = rawId.trim().toLowerCase();
17
+ if (trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('..')) {
18
+ throw new Error('Provider ID contains illegal path characters: "' + rawId + '"');
19
+ }
20
+
21
+ if (!PROVIDER_ID_REGEX.test(trimmed)) {
22
+ throw new Error('Provider ID does not match canonical slug format: "' + rawId + '"');
23
+ }
24
+
25
+ return trimmed;
26
+ }
27
+
28
+ /**
29
+ * Creates a cryptographically bound execution token.
30
+ *
31
+ * @param {string} providerId
32
+ * @param {string} capabilityId
33
+ * @param {string} actionId
34
+ * @param {object} [tokenPayload={}]
35
+ * @returns {string} SHA-256 bound token
36
+ */
37
+ function createBoundToken(providerId, capabilityId, actionId, tokenPayload = {}) {
38
+ const payload = JSON.stringify({
39
+ providerId,
40
+ capabilityId,
41
+ actionId,
42
+ token: tokenPayload
43
+ });
44
+ return crypto.createHash('sha256').update(payload, 'utf8').digest('hex');
45
+ }
46
+
47
+ /**
48
+ * Executes an action through the mandatory provider execution boundary.
49
+ *
50
+ * @param {object} actionRequest
51
+ * @param {object} adapter
52
+ * @param {object} options
53
+ * @param {object} options.registry - ProviderRegistry
54
+ * @param {object} options.ledger - Cost ledger
55
+ * @returns {Promise<object>|object} Execution result
56
+ */
57
+ function executeProviderAction(actionRequest, adapter, options = {}) {
58
+ if (!actionRequest || typeof actionRequest !== 'object') {
59
+ const err = new Error('Action request must be an object.');
60
+ err.reasonCode = 'UNDECLARED_BILLABLE_ACTION';
61
+ throw err;
62
+ }
63
+
64
+ // 1. Normalize Provider Identity (Bypass & Alias Protection)
65
+ let normalizedProviderId;
66
+ try {
67
+ normalizedProviderId = normalizeProviderId(actionRequest.provider_id);
68
+ } catch (aliasErr) {
69
+ const err = new Error('Provider alias or ID rejected: ' + aliasErr.message);
70
+ err.reasonCode = 'UNKNOWN_PROVIDER_IDENTITY';
71
+ throw err;
72
+ }
73
+
74
+ const req = {
75
+ ...actionRequest,
76
+ provider_id: normalizedProviderId
77
+ };
78
+
79
+ // 2. Gate 1: ProviderCapabilityGate
80
+ const capDecision = evaluateProviderCapability(req, options.registry, {
81
+ ...options,
82
+ adapter
83
+ });
84
+
85
+ if (!capDecision.authorized) {
86
+ const err = new Error('Provider capability denied: ' + capDecision.message);
87
+ err.reasonCode = capDecision.reasonCode;
88
+ err.decision = capDecision;
89
+ throw err;
90
+ }
91
+
92
+ // 3. Gate 2: BillableActionGate
93
+ const billDecision = evaluateBillableAction(req, options.ledger, options);
94
+
95
+ if (!billDecision.authorized) {
96
+ const err = new Error('Billable action denied: ' + billDecision.message);
97
+ err.reasonCode = billDecision.reasonCode;
98
+ err.decision = billDecision;
99
+ throw err;
100
+ }
101
+
102
+ // 4. Bound Context Token
103
+ const boundToken = createBoundToken(
104
+ normalizedProviderId,
105
+ req.capability_id,
106
+ req.action_id,
107
+ req.authorization_token
108
+ );
109
+
110
+ const executionContext = {
111
+ provider_id: normalizedProviderId,
112
+ capability_id: req.capability_id,
113
+ action_id: req.action_id,
114
+ boundToken,
115
+ authorized: true
116
+ };
117
+
118
+ if (!adapter || typeof adapter.execute !== 'function') {
119
+ const err = new Error('Adapter missing execute() method.');
120
+ err.reasonCode = 'PROVIDER_CAPABILITY_UNSUPPORTED';
121
+ throw err;
122
+ }
123
+
124
+ return adapter.execute(req, executionContext);
125
+ }
126
+
127
+ /**
128
+ * Executes an action with re-entrant fallback chains.
129
+ * Re-evaluates all gates independently for each candidate; authorization is NEVER inherited.
130
+ *
131
+ * @param {object} primaryCandidate - { request, adapter }
132
+ * @param {Array<object>} fallbackCandidates - Array of { request, adapter }
133
+ * @param {object} options
134
+ * @returns {object} Execution result
135
+ */
136
+ function executeWithFallback(primaryCandidate, fallbackCandidates = [], options = {}) {
137
+ try {
138
+ const result = executeProviderAction(primaryCandidate.request, primaryCandidate.adapter, options);
139
+ return {
140
+ success: true,
141
+ provider_id: primaryCandidate.request.provider_id,
142
+ result,
143
+ fallbackUsed: false
144
+ };
145
+ } catch (primaryErr) {
146
+ // Primary failed (network error, rate limit, denial, etc.)
147
+ // Iterate fallback candidates
148
+ for (let i = 0; i < fallbackCandidates.length; i++) {
149
+ const fb = fallbackCandidates[i];
150
+
151
+ // Re-enter gates independently!
152
+ // Invariant: fallback provider != inherited authorization
153
+ // If fallback candidate request does not satisfy spending gates independently -> emit PROVIDER_FALLBACK_UNAUTHORIZED
154
+ try {
155
+ const fbResult = executeProviderAction(fb.request, fb.adapter, options);
156
+ return {
157
+ success: true,
158
+ provider_id: fb.request.provider_id,
159
+ result: fbResult,
160
+ fallbackUsed: true,
161
+ fallbackIndex: i
162
+ };
163
+ } catch (fbErr) {
164
+ if (fbErr.reasonCode === 'BILLABLE_ACTION_UNAUTHORIZED' || fbErr.reasonCode === 'ENV_COMMERCIAL_DENIED') {
165
+ const fallbackErr = new Error('Fallback provider "' + (fb.request ? fb.request.provider_id : 'unknown') + '" failed authorization: ' + fbErr.message);
166
+ fallbackErr.reasonCode = 'PROVIDER_FALLBACK_UNAUTHORIZED';
167
+ fallbackErr.originalReason = fbErr.reasonCode;
168
+ throw fallbackErr;
169
+ }
170
+ // If it was an execution error (not authorization), continue to next fallback if available
171
+ if (i === fallbackCandidates.length - 1) {
172
+ throw fbErr;
173
+ }
174
+ }
175
+ }
176
+
177
+ throw primaryErr;
178
+ }
179
+ }
180
+
181
+ module.exports = {
182
+ normalizeProviderId,
183
+ createBoundToken,
184
+ executeProviderAction,
185
+ executeWithFallback
186
+ };