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.
- package/.gemstack/state.json +11 -10
- package/CHANGELOG.md +97 -0
- package/README.md +89 -9
- package/RELEASE_NOTES.md +77 -0
- package/{gemstack-ai-1.2.0.tgz → gemstack-ai-1.4.0.tgz} +0 -0
- package/handoff.md +14 -12
- package/package.json +2 -2
- package/specs/008-cost-provider-safety-gates/.gemstack.json +5 -0
- package/specs/008-cost-provider-safety-gates/closure.json +59 -0
- package/specs/008-cost-provider-safety-gates/plan.md +456 -0
- package/specs/008-cost-provider-safety-gates/spec.md +633 -0
- package/specs/008-cost-provider-safety-gates/tasks.md +635 -0
- package/specs/009-context-capsule/closure.json +59 -0
- package/specs/009-context-capsule/context-capsule.json +428 -0
- package/specs/009-context-capsule/plan.md +663 -0
- package/specs/009-context-capsule/spec.md +913 -0
- package/specs/009-context-capsule/tasks.md +720 -0
- 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/src/cli.js +10 -0
- package/src/commands/context.js +95 -0
- package/src/commands/swarm.js +111 -0
- package/src/commands/verify.js +92 -0
- package/src/commands/visual.js +82 -0
- package/src/lib/closure-context.js +18 -1
- package/src/lib/context-capsule.js +594 -0
- package/src/lib/cost-ledger.js +355 -0
- package/src/lib/provider-boundary.js +186 -0
- package/src/lib/provider-registry.js +265 -0
- package/src/lib/safety-gates.js +277 -0
- package/src/lib/swarm.js +639 -0
- package/src/lib/visual-qa.js +499 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { createFinding } = require('./findings');
|
|
4
|
+
|
|
5
|
+
const VALID_ENV_TIERS = ['test', 'ci', 'development', 'staging', 'production'];
|
|
6
|
+
const PROVIDER_ID_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
7
|
+
const REMOTE_URL_PATTERN = /^https?:\/\//i;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolves the deterministic environment tier.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} [options={}]
|
|
13
|
+
* @param {string} [options.environment]
|
|
14
|
+
* @param {object} [options.env=process.env]
|
|
15
|
+
* @returns {string} One of: 'test', 'ci', 'development', 'staging', 'production'
|
|
16
|
+
*/
|
|
17
|
+
function resolveEnvironmentTier(options = {}) {
|
|
18
|
+
const env = options.env || process.env;
|
|
19
|
+
|
|
20
|
+
if (options.environment && VALID_ENV_TIERS.includes(options.environment)) {
|
|
21
|
+
return options.environment;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (env.GEMSTACK_ENV && VALID_ENV_TIERS.includes(env.GEMSTACK_ENV)) {
|
|
25
|
+
return env.GEMSTACK_ENV;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Detect CI first
|
|
29
|
+
if (env.CI === 'true' || env.CI === '1' || env.CONTINUOUS_INTEGRATION === 'true') {
|
|
30
|
+
return 'ci';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Detect test runner
|
|
34
|
+
if (env.NODE_ENV === 'test' || typeof global.it === 'function' || typeof global.test === 'function' || process.argv.includes('--test')) {
|
|
35
|
+
return 'test';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return 'development';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validates whether commercial providers are permitted in a given environment tier.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} envTier
|
|
45
|
+
* @param {object} [options={}]
|
|
46
|
+
* @param {boolean} [options.allowBillable=false]
|
|
47
|
+
* @param {object} [options.ciWaiver=null]
|
|
48
|
+
* @returns {{ allowed: boolean, reasonCode: string, message: string }}
|
|
49
|
+
*/
|
|
50
|
+
function checkEnvironmentCommercialPolicy(envTier, options = {}) {
|
|
51
|
+
if (envTier === 'test') {
|
|
52
|
+
return {
|
|
53
|
+
allowed: false,
|
|
54
|
+
reasonCode: 'ENV_COMMERCIAL_DENIED',
|
|
55
|
+
message: 'Commercial provider invocation is strictly denied in test environment.'
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (envTier === 'ci') {
|
|
60
|
+
if (options.ciWaiver && options.ciWaiver.signed === true && options.ciWaiver.expires_at > Date.now()) {
|
|
61
|
+
return {
|
|
62
|
+
allowed: true,
|
|
63
|
+
reasonCode: 'CI_WAIVER_ACCEPTED',
|
|
64
|
+
message: 'Commercial invocation permitted in CI via explicit signed waiver.'
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
allowed: false,
|
|
69
|
+
reasonCode: 'ENV_COMMERCIAL_DENIED',
|
|
70
|
+
message: 'Commercial provider invocation is denied in CI environment without explicit signed waiver.'
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (envTier === 'development') {
|
|
75
|
+
if (!options.allowBillable) {
|
|
76
|
+
return {
|
|
77
|
+
allowed: false,
|
|
78
|
+
reasonCode: 'BILLABLE_ACTION_UNAUTHORIZED',
|
|
79
|
+
message: 'Commercial provider actions in development require explicit authorization (--allow-billable).'
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
allowed: true,
|
|
84
|
+
reasonCode: 'DEV_AUTHORIZED',
|
|
85
|
+
message: 'Commercial execution authorized for development session.'
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// staging / production
|
|
90
|
+
return {
|
|
91
|
+
allowed: true,
|
|
92
|
+
reasonCode: 'PRODUCTION_ALLOWED',
|
|
93
|
+
message: 'Commercial execution permitted under managed environment.'
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validates integrity of a MOCK provider configuration to prevent network escape.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} providerConfig
|
|
101
|
+
* @returns {{ valid: boolean, reasonCode: string|null, message: string|null }}
|
|
102
|
+
*/
|
|
103
|
+
function validateMockIntegrity(providerConfig) {
|
|
104
|
+
if (!providerConfig || typeof providerConfig !== 'object') {
|
|
105
|
+
return {
|
|
106
|
+
valid: false,
|
|
107
|
+
reasonCode: 'MOCK_PROVIDER_ESCAPE_VIOLATION',
|
|
108
|
+
message: 'Mock provider configuration must be a valid object.'
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (providerConfig.type !== 'MOCK') {
|
|
113
|
+
return {
|
|
114
|
+
valid: false,
|
|
115
|
+
reasonCode: 'MOCK_PROVIDER_ESCAPE_VIOLATION',
|
|
116
|
+
message: 'Provider is not declared with type "MOCK".'
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Scan for remote endpoints, urls, or external host configurations
|
|
121
|
+
const forbiddenFields = ['url', 'endpoint', 'baseUrl', 'base_url', 'host', 'remoteUrl', 'remote_url'];
|
|
122
|
+
for (const field of forbiddenFields) {
|
|
123
|
+
if (typeof providerConfig[field] === 'string' && providerConfig[field].trim()) {
|
|
124
|
+
const val = providerConfig[field].trim();
|
|
125
|
+
if (REMOTE_URL_PATTERN.test(val) || val.includes('://') || (!val.includes('localhost') && !val.includes('127.0.0.1') && val.includes('.'))) {
|
|
126
|
+
return {
|
|
127
|
+
valid: false,
|
|
128
|
+
reasonCode: 'MOCK_PROVIDER_ESCAPE_VIOLATION',
|
|
129
|
+
message: 'Mock provider declared forbidden remote endpoint at field "' + field + '": ' + val
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Scan nested capabilities for remote endpoint overrides
|
|
136
|
+
if (providerConfig.capabilities && typeof providerConfig.capabilities === 'object') {
|
|
137
|
+
for (const [capId, cap] of Object.entries(providerConfig.capabilities)) {
|
|
138
|
+
if (cap && typeof cap === 'object') {
|
|
139
|
+
for (const field of forbiddenFields) {
|
|
140
|
+
if (typeof cap[field] === 'string' && cap[field].trim()) {
|
|
141
|
+
return {
|
|
142
|
+
valid: false,
|
|
143
|
+
reasonCode: 'MOCK_PROVIDER_ESCAPE_VIOLATION',
|
|
144
|
+
message: 'Mock capability "' + capId + '" declared forbidden remote endpoint at "' + field + '".'
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
valid: true,
|
|
154
|
+
reasonCode: null,
|
|
155
|
+
message: null
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Creates an in-memory ProviderRegistry instance.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} providersMap
|
|
163
|
+
* @returns {object} ProviderRegistry
|
|
164
|
+
*/
|
|
165
|
+
function createProviderRegistry(providersMap = {}) {
|
|
166
|
+
const normalizedProviders = new Map();
|
|
167
|
+
|
|
168
|
+
for (const [rawId, config] of Object.entries(providersMap)) {
|
|
169
|
+
const id = rawId.toLowerCase().trim();
|
|
170
|
+
if (!PROVIDER_ID_REGEX.test(id)) {
|
|
171
|
+
throw new Error('Invalid provider ID format: "' + rawId + '". Must match slug pattern: ' + PROVIDER_ID_REGEX.toString());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const type = (config.type || 'COMMERCIAL').toUpperCase();
|
|
175
|
+
const capabilities = new Map();
|
|
176
|
+
|
|
177
|
+
if (config.capabilities) {
|
|
178
|
+
if (Array.isArray(config.capabilities)) {
|
|
179
|
+
for (const cap of config.capabilities) {
|
|
180
|
+
capabilities.set(cap, { id: cap });
|
|
181
|
+
}
|
|
182
|
+
} else if (typeof config.capabilities === 'object') {
|
|
183
|
+
for (const [capId, capVal] of Object.entries(config.capabilities)) {
|
|
184
|
+
capabilities.set(capId, capVal);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
normalizedProviders.set(id, {
|
|
190
|
+
...config,
|
|
191
|
+
id,
|
|
192
|
+
type,
|
|
193
|
+
capabilities
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
hasProvider(providerId) {
|
|
199
|
+
if (typeof providerId !== 'string') return false;
|
|
200
|
+
return normalizedProviders.has(providerId.toLowerCase().trim());
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
getProvider(providerId) {
|
|
204
|
+
if (typeof providerId !== 'string') return null;
|
|
205
|
+
return normalizedProviders.get(providerId.toLowerCase().trim()) || null;
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
hasCapability(providerId, capabilityId) {
|
|
209
|
+
const p = this.getProvider(providerId);
|
|
210
|
+
if (!p) return false;
|
|
211
|
+
return p.capabilities.has(capabilityId);
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
getCapability(providerId, capabilityId) {
|
|
215
|
+
const p = this.getProvider(providerId);
|
|
216
|
+
if (!p) return null;
|
|
217
|
+
return p.capabilities.get(capabilityId) || null;
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
listProviders() {
|
|
221
|
+
return Array.from(normalizedProviders.values());
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Loads provider registry from repository artifacts or ledger.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} rootPath
|
|
230
|
+
* @param {string} [featureDir='']
|
|
231
|
+
* @returns {object} ProviderRegistry
|
|
232
|
+
*/
|
|
233
|
+
function loadProviderRegistry(rootPath, featureDir = '') {
|
|
234
|
+
const candidatePaths = [
|
|
235
|
+
path.join(rootPath, featureDir, 'cost-ledger.json'),
|
|
236
|
+
path.join(rootPath, 'cost-ledger.json'),
|
|
237
|
+
path.join(rootPath, '.gemstack/cost-ledger.json'),
|
|
238
|
+
path.join(rootPath, '.gemstack/providers.json')
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
for (const p of candidatePaths) {
|
|
242
|
+
if (fs.existsSync(p)) {
|
|
243
|
+
try {
|
|
244
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
245
|
+
if (raw.providers && typeof raw.providers === 'object') {
|
|
246
|
+
return createProviderRegistry(raw.providers);
|
|
247
|
+
}
|
|
248
|
+
} catch (e) {
|
|
249
|
+
// Fall through to next candidate
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return createProviderRegistry({});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = {
|
|
258
|
+
VALID_ENV_TIERS,
|
|
259
|
+
PROVIDER_ID_REGEX,
|
|
260
|
+
resolveEnvironmentTier,
|
|
261
|
+
checkEnvironmentCommercialPolicy,
|
|
262
|
+
validateMockIntegrity,
|
|
263
|
+
createProviderRegistry,
|
|
264
|
+
loadProviderRegistry
|
|
265
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
const { checkEnvironmentCommercialPolicy, resolveEnvironmentTier } = require('./provider-registry');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a normalized, structured gate decision object.
|
|
5
|
+
*
|
|
6
|
+
* @param {object} params
|
|
7
|
+
* @param {boolean} params.authorized
|
|
8
|
+
* @param {string} params.decision - 'ALLOW' | 'DENY'
|
|
9
|
+
* @param {string} params.reasonCode
|
|
10
|
+
* @param {string} params.message
|
|
11
|
+
* @param {object} [params.context={}]
|
|
12
|
+
* @returns {object} Structured gate decision
|
|
13
|
+
*/
|
|
14
|
+
function createGateDecision({ authorized, decision, reasonCode, message, context = {} }) {
|
|
15
|
+
// Strip any accidental credential fields from context
|
|
16
|
+
const cleanContext = {};
|
|
17
|
+
for (const [k, v] of Object.entries(context)) {
|
|
18
|
+
const lk = k.toLowerCase();
|
|
19
|
+
if (!lk.includes('key') && !lk.includes('secret') && !lk.includes('token') && !lk.includes('pass')) {
|
|
20
|
+
cleanContext[k] = v;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
authorized: Boolean(authorized),
|
|
26
|
+
decision: decision || (authorized ? 'ALLOW' : 'DENY'),
|
|
27
|
+
reasonCode,
|
|
28
|
+
reason_code: reasonCode, // backward/spec compatibility
|
|
29
|
+
message,
|
|
30
|
+
context: cleanContext
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* ProviderCapabilityGate: Validates provider registration, environment allowance,
|
|
36
|
+
* capability declaration, and adapter execution support.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} request
|
|
39
|
+
* @param {string} request.provider_id
|
|
40
|
+
* @param {string} request.capability_id
|
|
41
|
+
* @param {string} [request.environment]
|
|
42
|
+
* @param {object} [request.adapter]
|
|
43
|
+
* @param {object} registry - ProviderRegistry instance
|
|
44
|
+
* @param {object} [options={}]
|
|
45
|
+
* @returns {object} Structured gate decision
|
|
46
|
+
*/
|
|
47
|
+
function evaluateProviderCapability(request, registry, options = {}) {
|
|
48
|
+
if (!request || typeof request !== 'object') {
|
|
49
|
+
return createGateDecision({
|
|
50
|
+
authorized: false,
|
|
51
|
+
decision: 'DENY',
|
|
52
|
+
reasonCode: 'UNKNOWN_PROVIDER_IDENTITY',
|
|
53
|
+
message: 'Capability evaluation request must be an object.',
|
|
54
|
+
context: {}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const providerId = (request.provider_id || '').toLowerCase().trim();
|
|
59
|
+
const capabilityId = request.capability_id;
|
|
60
|
+
const envTier = resolveEnvironmentTier({ environment: request.environment, ...options });
|
|
61
|
+
|
|
62
|
+
// 1. Is Provider Registered?
|
|
63
|
+
if (!providerId || !registry || !registry.hasProvider(providerId)) {
|
|
64
|
+
return createGateDecision({
|
|
65
|
+
authorized: false,
|
|
66
|
+
decision: 'DENY',
|
|
67
|
+
reasonCode: 'UNKNOWN_PROVIDER_IDENTITY',
|
|
68
|
+
message: 'Provider "' + (request.provider_id || '') + '" is not registered in provider registry.',
|
|
69
|
+
context: { provider_id: request.provider_id, capability_id: capabilityId, environment: envTier }
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const provider = registry.getProvider(providerId);
|
|
74
|
+
|
|
75
|
+
// 2. Is Provider Permitted in Active Environment?
|
|
76
|
+
if (provider.type === 'COMMERCIAL') {
|
|
77
|
+
const envPolicy = checkEnvironmentCommercialPolicy(envTier, options);
|
|
78
|
+
if (!envPolicy.allowed) {
|
|
79
|
+
return createGateDecision({
|
|
80
|
+
authorized: false,
|
|
81
|
+
decision: 'DENY',
|
|
82
|
+
reasonCode: envPolicy.reasonCode,
|
|
83
|
+
message: envPolicy.message,
|
|
84
|
+
context: { provider_id: providerId, capability_id: capabilityId, environment: envTier }
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 3. Is Capability Declared for this Provider?
|
|
90
|
+
if (!capabilityId || !registry.hasCapability(providerId, capabilityId)) {
|
|
91
|
+
return createGateDecision({
|
|
92
|
+
authorized: false,
|
|
93
|
+
decision: 'DENY',
|
|
94
|
+
reasonCode: 'PROVIDER_CAPABILITY_UNDECLARED',
|
|
95
|
+
message: 'Capability "' + (capabilityId || '') + '" is not declared for provider "' + providerId + '".',
|
|
96
|
+
context: { provider_id: providerId, capability_id: capabilityId, environment: envTier }
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 4. Does Adapter Support Capability?
|
|
101
|
+
if (request.adapter) {
|
|
102
|
+
const hasAdapterSupport = typeof request.adapter.supportsCapability === 'function'
|
|
103
|
+
? request.adapter.supportsCapability(capabilityId)
|
|
104
|
+
: (Array.isArray(request.adapter.supportedCapabilities) && request.adapter.supportedCapabilities.includes(capabilityId));
|
|
105
|
+
|
|
106
|
+
if (!hasAdapterSupport) {
|
|
107
|
+
return createGateDecision({
|
|
108
|
+
authorized: false,
|
|
109
|
+
decision: 'DENY',
|
|
110
|
+
reasonCode: 'PROVIDER_CAPABILITY_UNSUPPORTED',
|
|
111
|
+
message: 'Adapter for provider "' + providerId + '" does not implement capability "' + capabilityId + '".',
|
|
112
|
+
context: { provider_id: providerId, capability_id: capabilityId, environment: envTier }
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 5. Success
|
|
118
|
+
return createGateDecision({
|
|
119
|
+
authorized: true,
|
|
120
|
+
decision: 'ALLOW',
|
|
121
|
+
reasonCode: 'CAPABILITY_VERIFIED',
|
|
122
|
+
message: 'Provider "' + providerId + '" capability "' + capabilityId + '" verified successfully.',
|
|
123
|
+
context: { provider_id: providerId, capability_id: capabilityId, environment: envTier }
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* BillableActionGate: Evaluates spending authorization, cost state classification,
|
|
129
|
+
* budget limits, and fail-closed commercial defaults.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} request
|
|
132
|
+
* @param {string} request.action_id
|
|
133
|
+
* @param {string} request.provider_id
|
|
134
|
+
* @param {string} request.capability_id
|
|
135
|
+
* @param {string} [request.environment]
|
|
136
|
+
* @param {number} [request.requested_units=1]
|
|
137
|
+
* @param {object} [request.authorization_token]
|
|
138
|
+
* @param {object} ledger - Loaded cost-ledger object or ProviderRegistry
|
|
139
|
+
* @param {object} [options={}]
|
|
140
|
+
* @param {string[]} [options.declaredActions] - Optional whitelist of declared actions in spec.md
|
|
141
|
+
* @returns {object} Structured gate decision
|
|
142
|
+
*/
|
|
143
|
+
function evaluateBillableAction(request, ledger, options = {}) {
|
|
144
|
+
if (!request || typeof request !== 'object') {
|
|
145
|
+
return createGateDecision({
|
|
146
|
+
authorized: false,
|
|
147
|
+
decision: 'DENY',
|
|
148
|
+
reasonCode: 'UNDECLARED_BILLABLE_ACTION',
|
|
149
|
+
message: 'Billable action request must be an object.',
|
|
150
|
+
context: {}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const actionId = request.action_id;
|
|
155
|
+
const providerId = (request.provider_id || '').toLowerCase().trim();
|
|
156
|
+
const capabilityId = request.capability_id;
|
|
157
|
+
const envTier = resolveEnvironmentTier({ environment: request.environment, ...options });
|
|
158
|
+
const requestedUnits = typeof request.requested_units === 'number' ? request.requested_units : 1;
|
|
159
|
+
|
|
160
|
+
// 1. Action Declaration Check (Must be explicitly declared)
|
|
161
|
+
if (!actionId || typeof actionId !== 'string' || !actionId.trim()) {
|
|
162
|
+
return createGateDecision({
|
|
163
|
+
authorized: false,
|
|
164
|
+
decision: 'DENY',
|
|
165
|
+
reasonCode: 'UNDECLARED_BILLABLE_ACTION',
|
|
166
|
+
message: 'Action ID is missing or invalid.',
|
|
167
|
+
context: { action_id: actionId, provider_id: providerId, environment: envTier }
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (options.declaredActions && Array.isArray(options.declaredActions)) {
|
|
172
|
+
if (!options.declaredActions.includes(actionId)) {
|
|
173
|
+
return createGateDecision({
|
|
174
|
+
authorized: false,
|
|
175
|
+
decision: 'DENY',
|
|
176
|
+
reasonCode: 'UNDECLARED_BILLABLE_ACTION',
|
|
177
|
+
message: 'Billable action "' + actionId + '" is not declared in specification or active plan.',
|
|
178
|
+
context: { action_id: actionId, provider_id: providerId, environment: envTier }
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Resolve Provider & Capability from Ledger
|
|
184
|
+
let providerEntry = null;
|
|
185
|
+
let capabilityEntry = null;
|
|
186
|
+
|
|
187
|
+
if (ledger && ledger.providers && ledger.providers[providerId]) {
|
|
188
|
+
providerEntry = ledger.providers[providerId];
|
|
189
|
+
if (providerEntry.capabilities && providerEntry.capabilities[capabilityId]) {
|
|
190
|
+
capabilityEntry = providerEntry.capabilities[capabilityId];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2. Cost State Resolution (Strict Fail-Closed)
|
|
195
|
+
const costState = capabilityEntry ? capabilityEntry.cost_state : (request.cost_state || 'UNKNOWN');
|
|
196
|
+
|
|
197
|
+
if (costState === 'UNKNOWN' || !['FREE', 'BILLABLE', 'POTENTIALLY_BILLABLE'].includes(costState)) {
|
|
198
|
+
return createGateDecision({
|
|
199
|
+
authorized: false,
|
|
200
|
+
decision: 'DENY',
|
|
201
|
+
reasonCode: 'UNKNOWN_COST_CLASSIFICATION',
|
|
202
|
+
message: 'Action "' + actionId + '" has UNKNOWN cost classification. Fail-closed: execution denied.',
|
|
203
|
+
context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: 'UNKNOWN', environment: envTier }
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 3. Environment Check for Commercial / Billable
|
|
208
|
+
const isCommercial = providerEntry ? providerEntry.type === 'COMMERCIAL' : (costState !== 'FREE');
|
|
209
|
+
if (isCommercial) {
|
|
210
|
+
const envPolicy = checkEnvironmentCommercialPolicy(envTier, options);
|
|
211
|
+
if (!envPolicy.allowed) {
|
|
212
|
+
return createGateDecision({
|
|
213
|
+
authorized: false,
|
|
214
|
+
decision: 'DENY',
|
|
215
|
+
reasonCode: envPolicy.reasonCode,
|
|
216
|
+
message: envPolicy.message,
|
|
217
|
+
context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier }
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 4. FREE Actions (Permitted for Local / MOCK / Zero Cost)
|
|
223
|
+
if (costState === 'FREE') {
|
|
224
|
+
return createGateDecision({
|
|
225
|
+
authorized: true,
|
|
226
|
+
decision: 'ALLOW',
|
|
227
|
+
reasonCode: 'ACTION_AUTHORIZED_FREE',
|
|
228
|
+
message: 'Action "' + actionId + '" is FREE and authorized for execution.',
|
|
229
|
+
context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: 'FREE', environment: envTier }
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 5. BILLABLE / POTENTIALLY_BILLABLE Actions -> Require Explicit Spending Token
|
|
234
|
+
const token = request.authorization_token;
|
|
235
|
+
const isTokenValid = token && (token.granted === true || token.granted_by || token.source === 'CLI_FLAG' || token.max_budget_units !== undefined);
|
|
236
|
+
|
|
237
|
+
if (!isTokenValid) {
|
|
238
|
+
return createGateDecision({
|
|
239
|
+
authorized: false,
|
|
240
|
+
decision: 'DENY',
|
|
241
|
+
reasonCode: 'BILLABLE_ACTION_UNAUTHORIZED',
|
|
242
|
+
message: 'Action "' + actionId + '" on provider "' + providerId + '" requires explicit spending authorization.',
|
|
243
|
+
context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier }
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 6. Budget & Unit Threshold Check
|
|
248
|
+
const estimatedUnitCost = (capabilityEntry && typeof capabilityEntry.estimated_unit_cost === 'number') ? capabilityEntry.estimated_unit_cost : 1;
|
|
249
|
+
const estimatedTotalCost = requestedUnits * estimatedUnitCost;
|
|
250
|
+
|
|
251
|
+
if (token && typeof token.max_budget_units === 'number') {
|
|
252
|
+
if (estimatedTotalCost > token.max_budget_units) {
|
|
253
|
+
return createGateDecision({
|
|
254
|
+
authorized: false,
|
|
255
|
+
decision: 'DENY',
|
|
256
|
+
reasonCode: 'BUDGET_THRESHOLD_EXCEEDED',
|
|
257
|
+
message: 'Action "' + actionId + '" estimated cost (' + estimatedTotalCost + ') exceeds authorized budget limit (' + token.max_budget_units + ').',
|
|
258
|
+
context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier, estimated_total_cost: estimatedTotalCost, max_budget_units: token.max_budget_units }
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 7. Full Authorization Pass
|
|
264
|
+
return createGateDecision({
|
|
265
|
+
authorized: true,
|
|
266
|
+
decision: 'ALLOW',
|
|
267
|
+
reasonCode: 'ACTION_AUTHORIZED',
|
|
268
|
+
message: 'Action "' + actionId + '" authorized for execution within budget.',
|
|
269
|
+
context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier, estimated_total_cost: estimatedTotalCost }
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
createGateDecision,
|
|
275
|
+
evaluateProviderCapability,
|
|
276
|
+
evaluateBillableAction
|
|
277
|
+
};
|