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
|
@@ -1,22 +1,92 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const fs = require('fs');
|
|
3
|
+
const crypto = require('crypto');
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
5
|
+
function normalizePlatformPath(p) {
|
|
6
|
+
const resolved = path.resolve(p);
|
|
7
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function resolveSafe(targetDir, relativePath) {
|
|
11
|
+
const target = path.resolve(targetDir);
|
|
12
|
+
const candidate = path.resolve(targetDir, relativePath);
|
|
13
|
+
const rel = path.relative(target, candidate);
|
|
14
|
+
|
|
15
|
+
const isInside = rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
16
|
+
|
|
17
|
+
if (!isInside) {
|
|
18
|
+
const err = new Error(`[PATH_TRAVERSAL_DETECTED] Path Traversal blocked: ${relativePath}`);
|
|
19
|
+
err.code = 'PATH_TRAVERSAL_DETECTED';
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
return candidate;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveSafeStrict(targetDir, relativePath) {
|
|
26
|
+
const candidate = resolveSafe(targetDir, relativePath);
|
|
27
|
+
const target = path.resolve(targetDir);
|
|
28
|
+
|
|
29
|
+
const realRoot = fs.existsSync(target) ? fs.realpathSync(target) : target;
|
|
30
|
+
const normRoot = normalizePlatformPath(realRoot);
|
|
31
|
+
|
|
32
|
+
// Check intermediate components and candidate for symlink escapes
|
|
33
|
+
const rel = path.relative(target, candidate);
|
|
34
|
+
const parts = rel.split(/[\\/]/).filter(Boolean);
|
|
35
|
+
|
|
36
|
+
let current = target;
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
current = path.join(current, part);
|
|
39
|
+
if (fs.existsSync(current)) {
|
|
40
|
+
const realCurrent = fs.realpathSync(current);
|
|
41
|
+
const normCurrent = normalizePlatformPath(realCurrent);
|
|
42
|
+
if (!normCurrent.startsWith(normRoot) || (normCurrent !== normRoot && normCurrent[normRoot.length] !== path.sep && normCurrent[normRoot.length] !== '/' && normCurrent[normRoot.length] !== '\\')) {
|
|
43
|
+
const err = new Error(`Symlink escape detected: path component "${part}" resolves to "${realCurrent}" outside project root "${realRoot}"`);
|
|
44
|
+
err.code = 'SYMLINK_ESCAPE_DETECTED';
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (fs.existsSync(candidate)) {
|
|
51
|
+
const realCandidate = fs.realpathSync(candidate);
|
|
52
|
+
const normCandidate = normalizePlatformPath(realCandidate);
|
|
53
|
+
if (!normCandidate.startsWith(normRoot)) {
|
|
54
|
+
const err = new Error(`Symlink escape detected: destination resolves to "${realCandidate}" outside project root "${realRoot}"`);
|
|
55
|
+
err.code = 'SYMLINK_ESCAPE_DETECTED';
|
|
56
|
+
throw err;
|
|
14
57
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return candidate;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ensureDir(dirPath) {
|
|
64
|
+
if (!fs.existsSync(dirPath)) {
|
|
65
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function withConfinedAtomicWrite(rootDir, relativePath, writeFn) {
|
|
70
|
+
const finalPath = resolveSafeStrict(rootDir, relativePath);
|
|
71
|
+
const tmpDir = path.join(path.resolve(rootDir), '.gemstack', 'tmp');
|
|
72
|
+
ensureDir(tmpDir);
|
|
73
|
+
|
|
74
|
+
const tmpFile = path.join(tmpDir, `atomic-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
75
|
+
try {
|
|
76
|
+
writeFn(tmpFile);
|
|
77
|
+
ensureDir(path.dirname(finalPath));
|
|
78
|
+
fs.renameSync(tmpFile, finalPath);
|
|
79
|
+
return finalPath;
|
|
80
|
+
} finally {
|
|
81
|
+
if (fs.existsSync(tmpFile)) {
|
|
82
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
20
83
|
}
|
|
21
84
|
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
resolveSafe,
|
|
89
|
+
resolveSafeStrict,
|
|
90
|
+
ensureDir,
|
|
91
|
+
withConfinedAtomicWrite
|
|
22
92
|
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Memory Cross-Audit Engine (Gemstack 2.0 Sprint D)
|
|
5
|
+
* Reconciles git commit log with handoff.md to detect unrecorded work and memory drift.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { spawnSync } = require('child_process');
|
|
11
|
+
const fssafe = require('./filesystem-safe');
|
|
12
|
+
|
|
13
|
+
const MANDATORY_SECTIONS = [
|
|
14
|
+
'1. Objetivo',
|
|
15
|
+
'2. Estado actual',
|
|
16
|
+
'3. Archivos y cambios',
|
|
17
|
+
'4. Intentos fallidos',
|
|
18
|
+
'5. Próximos pasos'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extracts recent git commits offline from the repository log.
|
|
23
|
+
* @param {string} targetDir
|
|
24
|
+
* @param {number} limit
|
|
25
|
+
* @returns {Array<{ hash: string, message: string }>}
|
|
26
|
+
*/
|
|
27
|
+
function getRecentGitCommits(targetDir, limit = 5) {
|
|
28
|
+
try {
|
|
29
|
+
const gitRes = spawnSync('git', ['log', `-n${limit}`, '--oneline'], {
|
|
30
|
+
cwd: targetDir,
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
shell: false
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (gitRes.status !== 0 || !gitRes.stdout) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return gitRes.stdout
|
|
40
|
+
.trim()
|
|
41
|
+
.split('\n')
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.map(line => {
|
|
44
|
+
const parts = line.trim().split(' ');
|
|
45
|
+
const hash = parts[0];
|
|
46
|
+
const message = parts.slice(1).join(' ');
|
|
47
|
+
return { hash, message };
|
|
48
|
+
});
|
|
49
|
+
} catch (_) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validates handoff integrity and cross-checks recent git commits against handoff records.
|
|
56
|
+
* @param {string} targetDir - Directory containing handoff.md
|
|
57
|
+
* @param {object} options - { commits?: Array<{ hash: string, message: string }>, limit?: number }
|
|
58
|
+
* @returns {{ valid: boolean, handoff_intact: boolean, unrecorded_commits: Array<object>, error?: string }}
|
|
59
|
+
*/
|
|
60
|
+
function crossAuditMemoryWithGit(targetDir, options = {}) {
|
|
61
|
+
const handoffPath = fssafe.resolveSafe(targetDir, 'handoff.md');
|
|
62
|
+
if (!fs.existsSync(handoffPath)) {
|
|
63
|
+
return {
|
|
64
|
+
valid: false,
|
|
65
|
+
handoff_intact: false,
|
|
66
|
+
unrecorded_commits: [],
|
|
67
|
+
error: 'handoff.md no encontrado en el directorio raíz'
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const handoffContent = fs.readFileSync(handoffPath, 'utf8');
|
|
72
|
+
|
|
73
|
+
// 1. Verify mandatory sections
|
|
74
|
+
for (const sec of MANDATORY_SECTIONS) {
|
|
75
|
+
if (!handoffContent.includes(sec)) {
|
|
76
|
+
return {
|
|
77
|
+
valid: false,
|
|
78
|
+
handoff_intact: false,
|
|
79
|
+
missing_section: sec,
|
|
80
|
+
unrecorded_commits: [],
|
|
81
|
+
error: `handoff.md carece de la sección obligatoria: "${sec}".`
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 2. Obtain commits to check
|
|
87
|
+
const commits = options.commits || getRecentGitCommits(targetDir, options.limit || 5);
|
|
88
|
+
const unrecordedCommits = [];
|
|
89
|
+
|
|
90
|
+
for (const c of commits) {
|
|
91
|
+
// Check if commit hash or key terms of message are mentioned in handoff
|
|
92
|
+
const hashFound = c.hash && handoffContent.toLowerCase().includes(c.hash.toLowerCase());
|
|
93
|
+
|
|
94
|
+
// Extract meaningful words (length >= 5) from commit message
|
|
95
|
+
const words = c.message
|
|
96
|
+
? c.message
|
|
97
|
+
.replace(/[^\w\s-]/g, '')
|
|
98
|
+
.split(/\s+/)
|
|
99
|
+
.filter(w => w.length >= 5)
|
|
100
|
+
: [];
|
|
101
|
+
|
|
102
|
+
const wordsFound = words.length > 0 && words.some(w => handoffContent.toLowerCase().includes(w.toLowerCase()));
|
|
103
|
+
|
|
104
|
+
if (!hashFound && !wordsFound) {
|
|
105
|
+
unrecordedCommits.push(c);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
valid: unrecordedCommits.length === 0,
|
|
111
|
+
handoff_intact: true,
|
|
112
|
+
unrecorded_commits: unrecordedCommits,
|
|
113
|
+
recent_commits_checked: commits.length
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
MANDATORY_SECTIONS,
|
|
119
|
+
getRecentGitCommits,
|
|
120
|
+
crossAuditMemoryWithGit
|
|
121
|
+
};
|
|
@@ -34,13 +34,17 @@ function normalizeProviderId(rawId) {
|
|
|
34
34
|
* @param {object} [tokenPayload={}]
|
|
35
35
|
* @returns {string} SHA-256 bound token
|
|
36
36
|
*/
|
|
37
|
-
function createBoundToken(providerId, capabilityId, actionId, tokenPayload = {}) {
|
|
37
|
+
function createBoundToken(providerId, capabilityId, actionId, tokenPayload = {}, secret = null) {
|
|
38
38
|
const payload = JSON.stringify({
|
|
39
39
|
providerId,
|
|
40
40
|
capabilityId,
|
|
41
41
|
actionId,
|
|
42
42
|
token: tokenPayload
|
|
43
43
|
});
|
|
44
|
+
const effectiveSecret = secret || process.env.GEMSTACK_BOUNDARY_SECRET;
|
|
45
|
+
if (effectiveSecret) {
|
|
46
|
+
return crypto.createHmac('sha256', effectiveSecret).update(payload, 'utf8').digest('hex');
|
|
47
|
+
}
|
|
44
48
|
return crypto.createHash('sha256').update(payload, 'utf8').digest('hex');
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -17,12 +17,14 @@ const REMOTE_URL_PATTERN = /^https?:\/\//i;
|
|
|
17
17
|
function resolveEnvironmentTier(options = {}) {
|
|
18
18
|
const env = options.env || process.env;
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const rawEnv = (options.environment || '').toLowerCase().trim();
|
|
21
|
+
if (rawEnv && VALID_ENV_TIERS.includes(rawEnv)) {
|
|
22
|
+
return rawEnv;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
const rawGemstackEnv = (env.GEMSTACK_ENV || '').toLowerCase().trim();
|
|
26
|
+
if (rawGemstackEnv && VALID_ENV_TIERS.includes(rawGemstackEnv)) {
|
|
27
|
+
return rawGemstackEnv;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
// Detect CI first
|
package/src/lib/safety-gates.js
CHANGED
|
@@ -1,5 +1,71 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
1
2
|
const { checkEnvironmentCommercialPolicy, resolveEnvironmentTier } = require('./provider-registry');
|
|
2
3
|
|
|
4
|
+
const tokenSpendingLedger = new Map();
|
|
5
|
+
|
|
6
|
+
function resetTokenSpendingLedger() {
|
|
7
|
+
tokenSpendingLedger.clear();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Issues a cryptographically signed spending token from a trusted boundary.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} params
|
|
14
|
+
* @param {string} [params.secret]
|
|
15
|
+
* @param {string} [params.provider_id='*']
|
|
16
|
+
* @param {string} [params.action_id='*']
|
|
17
|
+
* @param {number} [params.max_budget_units=100]
|
|
18
|
+
* @param {number} [params.ttl_seconds=3600]
|
|
19
|
+
* @returns {object} Signed spending token
|
|
20
|
+
*/
|
|
21
|
+
function issueSpendingToken({ secret, provider_id = '*', action_id = '*', max_budget_units = 100, ttl_seconds = 3600 } = {}) {
|
|
22
|
+
const tokenId = (crypto.randomUUID && typeof crypto.randomUUID === 'function')
|
|
23
|
+
? crypto.randomUUID()
|
|
24
|
+
: crypto.randomBytes(16).toString('hex');
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const issuedAt = new Date(now).toISOString();
|
|
27
|
+
const expiresAt = new Date(now + ttl_seconds * 1000).toISOString();
|
|
28
|
+
const effectiveSecret = secret || process.env.GEMSTACK_BOUNDARY_SECRET || 'gemstack-boundary-internal-signing-secret';
|
|
29
|
+
|
|
30
|
+
const payload = [tokenId, provider_id, action_id, String(max_budget_units), expiresAt].join(':');
|
|
31
|
+
const signature = crypto.createHmac('sha256', effectiveSecret).update(payload).digest('hex');
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
token_id: tokenId,
|
|
35
|
+
provider_id,
|
|
36
|
+
action_id,
|
|
37
|
+
max_budget_units: Number(max_budget_units),
|
|
38
|
+
issued_at: issuedAt,
|
|
39
|
+
expires_at: expiresAt,
|
|
40
|
+
signature,
|
|
41
|
+
spent_units: 0
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Verifies a spending token signature and expiration.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} token
|
|
49
|
+
* @param {string} [secret]
|
|
50
|
+
* @returns {{ valid: boolean, reason?: string }}
|
|
51
|
+
*/
|
|
52
|
+
function verifySpendingToken(token, secret) {
|
|
53
|
+
if (!token || typeof token !== 'object') return { valid: false, reason: 'TOKEN_INVALID_FORMAT' };
|
|
54
|
+
if (!token.token_id || !token.signature || !token.expires_at) return { valid: false, reason: 'TOKEN_INCOMPLETE' };
|
|
55
|
+
|
|
56
|
+
const effectiveSecret = secret || process.env.GEMSTACK_BOUNDARY_SECRET || 'gemstack-boundary-internal-signing-secret';
|
|
57
|
+
const payload = [token.token_id, token.provider_id || '*', token.action_id || '*', String(token.max_budget_units), token.expires_at].join(':');
|
|
58
|
+
const expectedSig = crypto.createHmac('sha256', effectiveSecret).update(payload).digest('hex');
|
|
59
|
+
|
|
60
|
+
const bufA = Buffer.from(token.signature);
|
|
61
|
+
const bufB = Buffer.from(expectedSig);
|
|
62
|
+
if (bufA.length !== bufB.length || !crypto.timingSafeEqual(bufA, bufB)) {
|
|
63
|
+
return { valid: false, reason: 'TOKEN_SIGNATURE_INVALID' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { valid: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
3
69
|
/**
|
|
4
70
|
* Creates a normalized, structured gate decision object.
|
|
5
71
|
*
|
|
@@ -155,7 +221,17 @@ function evaluateBillableAction(request, ledger, options = {}) {
|
|
|
155
221
|
const providerId = (request.provider_id || '').toLowerCase().trim();
|
|
156
222
|
const capabilityId = request.capability_id;
|
|
157
223
|
const envTier = resolveEnvironmentTier({ environment: request.environment, ...options });
|
|
158
|
-
const requestedUnits =
|
|
224
|
+
const requestedUnits = request.requested_units !== undefined ? request.requested_units : 1;
|
|
225
|
+
|
|
226
|
+
if (typeof requestedUnits !== 'number' || !Number.isFinite(requestedUnits) || requestedUnits <= 0) {
|
|
227
|
+
return createGateDecision({
|
|
228
|
+
authorized: false,
|
|
229
|
+
decision: 'DENY',
|
|
230
|
+
reasonCode: 'INVALID_REQUESTED_UNITS',
|
|
231
|
+
message: 'Requested units must be a strictly positive finite number.',
|
|
232
|
+
context: { action_id: actionId, provider_id: providerId, requested_units: requestedUnits }
|
|
233
|
+
});
|
|
234
|
+
}
|
|
159
235
|
|
|
160
236
|
// 1. Action Declaration Check (Must be explicitly declared)
|
|
161
237
|
if (!actionId || typeof actionId !== 'string' || !actionId.trim()) {
|
|
@@ -232,9 +308,7 @@ function evaluateBillableAction(request, ledger, options = {}) {
|
|
|
232
308
|
|
|
233
309
|
// 5. BILLABLE / POTENTIALLY_BILLABLE Actions -> Require Explicit Spending Token
|
|
234
310
|
const token = request.authorization_token;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (!isTokenValid) {
|
|
311
|
+
if (!token || typeof token !== 'object') {
|
|
238
312
|
return createGateDecision({
|
|
239
313
|
authorized: false,
|
|
240
314
|
decision: 'DENY',
|
|
@@ -244,20 +318,111 @@ function evaluateBillableAction(request, ledger, options = {}) {
|
|
|
244
318
|
});
|
|
245
319
|
}
|
|
246
320
|
|
|
321
|
+
const boundarySecret = options.boundarySecret || process.env.GEMSTACK_BOUNDARY_SECRET;
|
|
322
|
+
|
|
323
|
+
if (boundarySecret) {
|
|
324
|
+
const verified = verifySpendingToken(token, boundarySecret);
|
|
325
|
+
if (!verified.valid) {
|
|
326
|
+
return createGateDecision({
|
|
327
|
+
authorized: false,
|
|
328
|
+
decision: 'DENY',
|
|
329
|
+
reasonCode: 'BILLABLE_ACTION_UNAUTHORIZED',
|
|
330
|
+
message: 'Authorization token rejected by trusted boundary: ' + verified.reason,
|
|
331
|
+
context: { action_id: actionId, provider_id: providerId, reason: verified.reason }
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
} else if (token.signature && token.token_id) {
|
|
335
|
+
const verified = verifySpendingToken(token);
|
|
336
|
+
if (!verified.valid) {
|
|
337
|
+
return createGateDecision({
|
|
338
|
+
authorized: false,
|
|
339
|
+
decision: 'DENY',
|
|
340
|
+
reasonCode: 'BILLABLE_ACTION_UNAUTHORIZED',
|
|
341
|
+
message: 'Authorization token signature invalid.',
|
|
342
|
+
context: { action_id: actionId, provider_id: providerId }
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
// Legacy fallback only when no boundarySecret is active
|
|
347
|
+
const isTokenValid = token && (token.granted === true || token.granted_by || token.source === 'CLI_FLAG' || token.max_budget_units !== undefined);
|
|
348
|
+
if (!isTokenValid) {
|
|
349
|
+
return createGateDecision({
|
|
350
|
+
authorized: false,
|
|
351
|
+
decision: 'DENY',
|
|
352
|
+
reasonCode: 'BILLABLE_ACTION_UNAUTHORIZED',
|
|
353
|
+
message: 'Action "' + actionId + '" on provider "' + providerId + '" requires explicit spending authorization.',
|
|
354
|
+
context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier }
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Expiration check
|
|
360
|
+
if (token.expires_at) {
|
|
361
|
+
const expMs = Date.parse(token.expires_at);
|
|
362
|
+
if (!Number.isNaN(expMs) && Date.now() > expMs) {
|
|
363
|
+
return createGateDecision({
|
|
364
|
+
authorized: false,
|
|
365
|
+
decision: 'DENY',
|
|
366
|
+
reasonCode: 'TOKEN_EXPIRED',
|
|
367
|
+
message: 'Authorization token expired at ' + token.expires_at + '.',
|
|
368
|
+
context: { action_id: actionId, provider_id: providerId, expires_at: token.expires_at }
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Scope check (Provider & Action)
|
|
374
|
+
if (token.provider_id && token.provider_id !== '*' && token.provider_id.toLowerCase() !== providerId.toLowerCase()) {
|
|
375
|
+
return createGateDecision({
|
|
376
|
+
authorized: false,
|
|
377
|
+
decision: 'DENY',
|
|
378
|
+
reasonCode: 'TOKEN_SCOPE_MISMATCH',
|
|
379
|
+
message: 'Token scope provider "' + token.provider_id + '" does not match requested provider "' + providerId + '".',
|
|
380
|
+
context: { action_id: actionId, provider_id: providerId, token_provider: token.provider_id }
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (token.action_id && token.action_id !== '*' && token.action_id !== actionId) {
|
|
385
|
+
return createGateDecision({
|
|
386
|
+
authorized: false,
|
|
387
|
+
decision: 'DENY',
|
|
388
|
+
reasonCode: 'TOKEN_SCOPE_MISMATCH',
|
|
389
|
+
message: 'Token scope action "' + token.action_id + '" does not match requested action "' + actionId + '".',
|
|
390
|
+
context: { action_id: actionId, provider_id: providerId, token_action: token.action_id }
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
247
394
|
// 6. Budget & Unit Threshold Check
|
|
248
395
|
const estimatedUnitCost = (capabilityEntry && typeof capabilityEntry.estimated_unit_cost === 'number') ? capabilityEntry.estimated_unit_cost : 1;
|
|
249
396
|
const estimatedTotalCost = requestedUnits * estimatedUnitCost;
|
|
250
397
|
|
|
251
398
|
if (token && typeof token.max_budget_units === 'number') {
|
|
252
|
-
|
|
399
|
+
const tokenId = token.token_id;
|
|
400
|
+
const currentSpent = tokenId ? (tokenSpendingLedger.get(tokenId) || 0) : 0;
|
|
401
|
+
const projectedTotal = currentSpent + estimatedTotalCost;
|
|
402
|
+
|
|
403
|
+
if (projectedTotal > token.max_budget_units) {
|
|
253
404
|
return createGateDecision({
|
|
254
405
|
authorized: false,
|
|
255
406
|
decision: 'DENY',
|
|
256
407
|
reasonCode: 'BUDGET_THRESHOLD_EXCEEDED',
|
|
257
|
-
message: 'Action "' + actionId + '" estimated cost (' +
|
|
258
|
-
context: {
|
|
408
|
+
message: 'Action "' + actionId + '" estimated cost (' + projectedTotal + ') exceeds authorized budget limit (' + token.max_budget_units + ').',
|
|
409
|
+
context: {
|
|
410
|
+
action_id: actionId,
|
|
411
|
+
provider_id: providerId,
|
|
412
|
+
capability_id: capabilityId,
|
|
413
|
+
cost_state: costState,
|
|
414
|
+
environment: envTier,
|
|
415
|
+
estimated_total_cost: estimatedTotalCost,
|
|
416
|
+
current_spent: currentSpent,
|
|
417
|
+
projected_total: projectedTotal,
|
|
418
|
+
max_budget_units: token.max_budget_units
|
|
419
|
+
}
|
|
259
420
|
});
|
|
260
421
|
}
|
|
422
|
+
|
|
423
|
+
if (tokenId) {
|
|
424
|
+
tokenSpendingLedger.set(tokenId, projectedTotal);
|
|
425
|
+
}
|
|
261
426
|
}
|
|
262
427
|
|
|
263
428
|
// 7. Full Authorization Pass
|
|
@@ -273,5 +438,8 @@ function evaluateBillableAction(request, ledger, options = {}) {
|
|
|
273
438
|
module.exports = {
|
|
274
439
|
createGateDecision,
|
|
275
440
|
evaluateProviderCapability,
|
|
276
|
-
evaluateBillableAction
|
|
441
|
+
evaluateBillableAction,
|
|
442
|
+
issueSpendingToken,
|
|
443
|
+
verifySpendingToken,
|
|
444
|
+
resetTokenSpendingLedger
|
|
277
445
|
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SDD Rigor Engine (Gemstack 2.0 Sprint C)
|
|
5
|
+
* Defines and enforces 4 levels of SDD rigor: quick, fix, feature, high-risk.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const RIGOR_LEVELS = Object.freeze(['quick', 'fix', 'feature', 'high-risk']);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Detects the declared rigor level from spec content or metadata.
|
|
12
|
+
* @param {string} specContent - Content of spec.md or quick.md
|
|
13
|
+
* @returns {string} Detected rigor level ('quick'|'fix'|'feature'|'high-risk')
|
|
14
|
+
*/
|
|
15
|
+
function detectRigorLevel(specContent) {
|
|
16
|
+
if (!specContent || typeof specContent !== 'string') {
|
|
17
|
+
return 'feature';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Check YAML frontmatter: rigor: <level>
|
|
21
|
+
const frontmatterMatch = specContent.match(/^---\s*[\r\n]+([\s\S]*?)[\r\n]+---/);
|
|
22
|
+
if (frontmatterMatch) {
|
|
23
|
+
const rMatch = frontmatterMatch[1].match(/^\s*rigor:\s*([a-zA-Z0-9_-]+)/m);
|
|
24
|
+
if (rMatch) {
|
|
25
|
+
const level = rMatch[1].trim().toLowerCase();
|
|
26
|
+
if (!RIGOR_LEVELS.includes(level)) {
|
|
27
|
+
const err = new Error(`Nivel de rigor inválido "${level}". Niveles válidos: ${RIGOR_LEVELS.join(', ')}`);
|
|
28
|
+
err.code = 'INVALID_RIGOR_LEVEL';
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
return level;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check Markdown annotations: **Rigor Level**: `level` or Rigor: level
|
|
36
|
+
const inlineMatch = specContent.match(/\*\*Rigor(?:\s+Level)?\*\*:\s*`?([a-zA-Z0-9_-]+)`?/i);
|
|
37
|
+
if (inlineMatch) {
|
|
38
|
+
const level = inlineMatch[1].trim().toLowerCase();
|
|
39
|
+
if (!RIGOR_LEVELS.includes(level)) {
|
|
40
|
+
const err = new Error(`Nivel de rigor inválido "${level}". Niveles válidos: ${RIGOR_LEVELS.join(', ')}`);
|
|
41
|
+
err.code = 'INVALID_RIGOR_LEVEL';
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
return level;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return 'feature';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validates requirements for a given rigor level.
|
|
52
|
+
* @param {string} rigorLevel - 'quick'|'fix'|'feature'|'high-risk'
|
|
53
|
+
* @param {object} context - Context containing specContent, planContent, tasksContent, testMatrix, sidecar, etc.
|
|
54
|
+
* @returns {{ valid: boolean, code?: string, error?: string, [key: string]: any }}
|
|
55
|
+
*/
|
|
56
|
+
function validateRigorRequirements(rigorLevel, context = {}) {
|
|
57
|
+
const normLevel = (rigorLevel || 'feature').toLowerCase();
|
|
58
|
+
if (!RIGOR_LEVELS.includes(normLevel)) {
|
|
59
|
+
const err = new Error(`Nivel de rigor inválido "${normLevel}".`);
|
|
60
|
+
err.code = 'INVALID_RIGOR_LEVEL';
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const {
|
|
65
|
+
specContent = '',
|
|
66
|
+
planContent = null,
|
|
67
|
+
tasksContent = null,
|
|
68
|
+
testMatrix = [],
|
|
69
|
+
sidecar = null
|
|
70
|
+
} = context;
|
|
71
|
+
|
|
72
|
+
switch (normLevel) {
|
|
73
|
+
case 'quick': {
|
|
74
|
+
if (!specContent || typeof specContent !== 'string' || specContent.trim().length === 0) {
|
|
75
|
+
return {
|
|
76
|
+
valid: false,
|
|
77
|
+
code: 'QUICK_SPEC_MISSING',
|
|
78
|
+
error: 'Quick rigor requires at least one spec or quick artifact'
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
valid: true,
|
|
83
|
+
rigor: 'quick',
|
|
84
|
+
single_artifact_allowed: true
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
case 'fix': {
|
|
89
|
+
// Must have at least one regression test in the test matrix
|
|
90
|
+
const matrix = Array.isArray(testMatrix) ? testMatrix : [];
|
|
91
|
+
const regressionTest = matrix.find(t => {
|
|
92
|
+
const cat = (t.category || '').toUpperCase();
|
|
93
|
+
const id = (t.id || '').toUpperCase();
|
|
94
|
+
const desc = (t.description || '').toLowerCase();
|
|
95
|
+
return cat === 'REGRESSION' || id.includes('REG') || desc.includes('regression') || desc.includes('reproduce');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (!regressionTest) {
|
|
99
|
+
return {
|
|
100
|
+
valid: false,
|
|
101
|
+
code: 'FIX_MISSING_REGRESSION_TEST',
|
|
102
|
+
error: 'Fix rigor requires at least one linked regression test in the test matrix (category REGRESSION or ID matching *REG*)'
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
valid: true,
|
|
108
|
+
rigor: 'fix',
|
|
109
|
+
regression_test_id: regressionTest.id
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
case 'feature': {
|
|
114
|
+
if (!specContent) {
|
|
115
|
+
return { valid: false, code: 'FEATURE_SPEC_MISSING', error: 'Feature rigor requires spec.md' };
|
|
116
|
+
}
|
|
117
|
+
if (!planContent) {
|
|
118
|
+
return { valid: false, code: 'FEATURE_PLAN_MISSING', error: 'Feature rigor requires plan.md' };
|
|
119
|
+
}
|
|
120
|
+
if (!tasksContent) {
|
|
121
|
+
return { valid: false, code: 'FEATURE_TASKS_MISSING', error: 'Feature rigor requires tasks.md' };
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
valid: true,
|
|
125
|
+
rigor: 'feature'
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case 'high-risk': {
|
|
130
|
+
// 1. Threat Model in specContent
|
|
131
|
+
const hasThreatModel = context.hasThreatModel ||
|
|
132
|
+
/##\s+(?:[0-9.]+\s+)?(?:Threat\s+Model|Modelo\s+de\s+Amenazas)/i.test(specContent);
|
|
133
|
+
if (!hasThreatModel) {
|
|
134
|
+
return {
|
|
135
|
+
valid: false,
|
|
136
|
+
code: 'HIGH_RISK_MISSING_THREAT_MODEL',
|
|
137
|
+
error: 'High-risk rigor requires an explicit Threat Model section in spec.md'
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 2. Rollback Plan in planContent
|
|
142
|
+
const hasRollbackPlan = context.hasRollbackPlan ||
|
|
143
|
+
(planContent && /##\s+(?:[0-9.]+\s+)?(?:Rollback\s+Plan|Plan\s+de\s+Rollback)/i.test(planContent));
|
|
144
|
+
if (!hasRollbackPlan) {
|
|
145
|
+
return {
|
|
146
|
+
valid: false,
|
|
147
|
+
code: 'HIGH_RISK_MISSING_ROLLBACK_PLAN',
|
|
148
|
+
error: 'High-risk rigor requires an explicit Rollback Plan section in plan.md'
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3. Dual human approvals in sidecar
|
|
153
|
+
const approvals = sidecar && Array.isArray(sidecar.approvals) ? sidecar.approvals : [];
|
|
154
|
+
const validApprovals = approvals.filter(a => a && a.approver && a.signature);
|
|
155
|
+
const uniqueApprovers = new Set(validApprovals.map(a => a.approver));
|
|
156
|
+
|
|
157
|
+
if (uniqueApprovers.size < 2) {
|
|
158
|
+
return {
|
|
159
|
+
valid: false,
|
|
160
|
+
code: 'HIGH_RISK_INSUFFICIENT_APPROVALS',
|
|
161
|
+
error: `High-risk rigor requires at least 2 distinct human approval signatures. Found: ${uniqueApprovers.size}`
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
valid: true,
|
|
167
|
+
rigor: 'high-risk',
|
|
168
|
+
approvals_count: uniqueApprovers.size
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
default:
|
|
173
|
+
return { valid: false, code: 'UNKNOWN_RIGOR', error: `Unknown rigor: ${normLevel}` };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
RIGOR_LEVELS,
|
|
179
|
+
detectRigorLevel,
|
|
180
|
+
validateRigorRequirements
|
|
181
|
+
};
|