antigravity-ai-kit 3.1.1 → 3.2.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/.agent/agents/planner.md +205 -62
- package/.agent/contexts/plan-quality-log.md +30 -0
- package/.agent/engine/loading-rules.json +37 -3
- package/.agent/hooks/hooks.json +10 -0
- package/.agent/manifest.json +4 -3
- package/.agent/skills/plan-validation/SKILL.md +192 -0
- package/.agent/skills/plan-writing/SKILL.md +47 -8
- package/.agent/skills/plan-writing/domain-enhancers.md +114 -0
- package/.agent/skills/plan-writing/plan-retrospective.md +116 -0
- package/.agent/skills/plan-writing/plan-schema.md +119 -0
- package/.agent/workflows/plan.md +49 -5
- package/README.md +30 -29
- package/bin/ag-kit.js +26 -5
- package/lib/agent-registry.js +17 -3
- package/lib/agent-reputation.js +3 -11
- package/lib/circuit-breaker.js +195 -0
- package/lib/cli-commands.js +88 -1
- package/lib/config-validator.js +274 -0
- package/lib/conflict-detector.js +29 -22
- package/lib/constants.js +35 -0
- package/lib/engineering-manager.js +9 -27
- package/lib/error-budget.js +105 -29
- package/lib/hook-system.js +8 -4
- package/lib/identity.js +22 -27
- package/lib/io.js +74 -0
- package/lib/loading-engine.js +248 -35
- package/lib/logger.js +118 -0
- package/lib/marketplace.js +43 -20
- package/lib/plugin-system.js +55 -31
- package/lib/plugin-verifier.js +197 -0
- package/lib/rate-limiter.js +113 -0
- package/lib/security-scanner.js +1 -4
- package/lib/self-healing.js +58 -24
- package/lib/session-manager.js +51 -48
- package/lib/skill-sandbox.js +1 -1
- package/lib/task-governance.js +10 -11
- package/lib/task-model.js +42 -27
- package/lib/updater.js +1 -1
- package/lib/verify.js +4 -4
- package/lib/workflow-engine.js +88 -68
- package/lib/workflow-events.js +166 -0
- package/lib/workflow-persistence.js +19 -19
- package/package.json +2 -2
package/lib/plugin-system.js
CHANGED
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
17
|
|
|
18
|
-
const AGENT_DIR = '
|
|
19
|
-
const
|
|
20
|
-
const
|
|
18
|
+
const { AGENT_DIR, ENGINE_DIR, PLUGINS_DIR, HOOKS_DIR } = require('./constants');
|
|
19
|
+
const { writeJsonAtomic } = require('./io');
|
|
20
|
+
const { createLogger } = require('./logger');
|
|
21
|
+
const log = createLogger('plugin-system');
|
|
21
22
|
const PLUGINS_REGISTRY = 'plugins-registry.json';
|
|
22
|
-
const HOOKS_DIR = 'hooks';
|
|
23
23
|
const HOOKS_FILE = 'hooks.json';
|
|
24
24
|
|
|
25
25
|
/** Required fields in plugin.json */
|
|
@@ -97,15 +97,7 @@ function loadRegistry(projectRoot) {
|
|
|
97
97
|
*/
|
|
98
98
|
function writeRegistry(projectRoot, registry) {
|
|
99
99
|
const registryPath = resolveRegistryPath(projectRoot);
|
|
100
|
-
|
|
101
|
-
const dir = path.dirname(registryPath);
|
|
102
|
-
|
|
103
|
-
if (!fs.existsSync(dir)) {
|
|
104
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
fs.writeFileSync(tempPath, JSON.stringify(registry, null, 2) + '\n', 'utf-8');
|
|
108
|
-
fs.renameSync(tempPath, registryPath);
|
|
100
|
+
writeJsonAtomic(registryPath, registry);
|
|
109
101
|
}
|
|
110
102
|
|
|
111
103
|
/**
|
|
@@ -168,7 +160,7 @@ function validatePlugin(pluginPath) {
|
|
|
168
160
|
}
|
|
169
161
|
|
|
170
162
|
// Validate hook event names
|
|
171
|
-
const validEvents = ['session-start', 'session-end', 'pre-commit', 'secret-detection', 'phase-transition', 'sprint-checkpoint'];
|
|
163
|
+
const validEvents = ['session-start', 'session-end', 'pre-commit', 'secret-detection', 'phase-transition', 'sprint-checkpoint', 'plan-complete'];
|
|
172
164
|
for (const hook of (manifest.hooks || [])) {
|
|
173
165
|
if (!hook.event || !validEvents.includes(hook.event)) {
|
|
174
166
|
errors.push(`Invalid hook event: ${hook.event || 'undefined'}. Valid: ${validEvents.join(', ')}`);
|
|
@@ -485,9 +477,7 @@ function mergeHooks(pluginHooks, pluginName, projectRoot) {
|
|
|
485
477
|
}
|
|
486
478
|
}
|
|
487
479
|
|
|
488
|
-
|
|
489
|
-
fs.writeFileSync(tempPath, JSON.stringify(hooksConfig, null, 2) + '\n', 'utf-8');
|
|
490
|
-
fs.renameSync(tempPath, hooksPath);
|
|
480
|
+
writeJsonAtomic(hooksPath, hooksConfig);
|
|
491
481
|
}
|
|
492
482
|
|
|
493
483
|
/**
|
|
@@ -514,9 +504,31 @@ function unmergeHooks(pluginName, projectRoot) {
|
|
|
514
504
|
// Remove hooks with no actions remaining
|
|
515
505
|
hooksConfig.hooks = hooksConfig.hooks.filter((h) => h.actions.length > 0);
|
|
516
506
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
507
|
+
writeJsonAtomic(hooksPath, hooksConfig);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Recursively sanitizes a value by stripping prototype-polluting keys
|
|
512
|
+
* at all nesting levels. Returns a clean copy without mutating the input.
|
|
513
|
+
*
|
|
514
|
+
* @param {*} val - Value to sanitize
|
|
515
|
+
* @returns {*} Sanitized copy
|
|
516
|
+
*/
|
|
517
|
+
function sanitizeValue(val) {
|
|
518
|
+
if (val === null || typeof val !== 'object') {
|
|
519
|
+
return val;
|
|
520
|
+
}
|
|
521
|
+
if (Array.isArray(val)) {
|
|
522
|
+
return val.map(sanitizeValue);
|
|
523
|
+
}
|
|
524
|
+
const clean = {};
|
|
525
|
+
for (const [k, v] of Object.entries(val)) {
|
|
526
|
+
if (k === '__proto__' || k === 'constructor' || k === 'prototype') {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
clean[k] = sanitizeValue(v);
|
|
530
|
+
}
|
|
531
|
+
return clean;
|
|
520
532
|
}
|
|
521
533
|
|
|
522
534
|
/**
|
|
@@ -529,28 +541,40 @@ function unmergeHooks(pluginName, projectRoot) {
|
|
|
529
541
|
*/
|
|
530
542
|
function applyEngineConfigs(configs, pluginName, projectRoot) {
|
|
531
543
|
for (const [configFile, patches] of Object.entries(configs)) {
|
|
544
|
+
// Reject path traversal in config file names
|
|
545
|
+
if (configFile.includes('/') || configFile.includes('\\') || configFile.includes('..')) {
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
|
|
532
549
|
const configPath = path.join(projectRoot, AGENT_DIR, ENGINE_DIR, configFile);
|
|
533
550
|
|
|
534
551
|
if (!fs.existsSync(configPath)) {
|
|
535
552
|
continue;
|
|
536
553
|
}
|
|
537
554
|
|
|
538
|
-
|
|
555
|
+
let config;
|
|
556
|
+
try {
|
|
557
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
558
|
+
} catch {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
539
561
|
|
|
540
|
-
//
|
|
562
|
+
// Deep merge patches with recursive prototype pollution guard (H-5)
|
|
541
563
|
for (const [key, value] of Object.entries(patches)) {
|
|
542
|
-
|
|
564
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
config[key] = sanitizeValue(value);
|
|
543
568
|
}
|
|
544
569
|
|
|
545
|
-
// Track which plugin patched this config
|
|
546
|
-
|
|
547
|
-
config._pluginPatches
|
|
548
|
-
|
|
549
|
-
|
|
570
|
+
// Track which plugin patched this config (immutable construction)
|
|
571
|
+
const pluginPatches = {
|
|
572
|
+
...(config._pluginPatches || {}),
|
|
573
|
+
[pluginName]: Object.keys(patches),
|
|
574
|
+
};
|
|
575
|
+
config._pluginPatches = pluginPatches;
|
|
550
576
|
|
|
551
|
-
|
|
552
|
-
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
553
|
-
fs.renameSync(tempPath, configPath);
|
|
577
|
+
writeJsonAtomic(configPath, config);
|
|
554
578
|
}
|
|
555
579
|
}
|
|
556
580
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Antigravity AI Kit — Plugin Signature Verification
|
|
3
|
+
*
|
|
4
|
+
* Generates and validates SHA-256 checksums for plugin integrity.
|
|
5
|
+
* Prevents supply chain attacks by verifying plugin contents
|
|
6
|
+
* have not been tampered with after installation.
|
|
7
|
+
*
|
|
8
|
+
* @module lib/plugin-verifier
|
|
9
|
+
* @author Emre Dursun
|
|
10
|
+
* @since v3.2.0
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const { AGENT_DIR, ENGINE_DIR, PLUGINS_DIR } = require('./constants');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} PluginChecksum
|
|
22
|
+
* @property {string} pluginName - Plugin name
|
|
23
|
+
* @property {string} checksum - SHA-256 checksum of concatenated file contents
|
|
24
|
+
* @property {string[]} files - Files included in checksum
|
|
25
|
+
* @property {string} generatedAt - ISO timestamp
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Collects all files in a directory recursively.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} dirPath - Directory to scan
|
|
32
|
+
* @returns {string[]} Sorted list of relative file paths
|
|
33
|
+
*/
|
|
34
|
+
function collectPluginFiles(dirPath) {
|
|
35
|
+
if (!fs.existsSync(dirPath)) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const files = [];
|
|
40
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
41
|
+
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
44
|
+
|
|
45
|
+
if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== '.git') {
|
|
46
|
+
files.push(...collectPluginFiles(fullPath));
|
|
47
|
+
} else if (entry.isFile()) {
|
|
48
|
+
files.push(fullPath);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return files.sort();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generates a SHA-256 checksum for a plugin directory.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} pluginDir - Path to plugin directory
|
|
59
|
+
* @returns {PluginChecksum}
|
|
60
|
+
*/
|
|
61
|
+
function generateChecksum(pluginDir) {
|
|
62
|
+
const files = collectPluginFiles(pluginDir);
|
|
63
|
+
const hash = crypto.createHash('sha256');
|
|
64
|
+
const relativeFiles = [];
|
|
65
|
+
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
const relativePath = path.relative(pluginDir, file).replace(/\\/g, '/');
|
|
68
|
+
relativeFiles.push(relativePath);
|
|
69
|
+
|
|
70
|
+
// Hash both the file path and contents for integrity
|
|
71
|
+
hash.update(relativePath);
|
|
72
|
+
hash.update(fs.readFileSync(file));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const manifestPath = path.join(pluginDir, 'plugin.json');
|
|
76
|
+
let pluginName = 'unknown';
|
|
77
|
+
if (fs.existsSync(manifestPath)) {
|
|
78
|
+
try {
|
|
79
|
+
pluginName = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')).name || 'unknown';
|
|
80
|
+
} catch {
|
|
81
|
+
// Use default
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
pluginName,
|
|
87
|
+
checksum: hash.digest('hex'),
|
|
88
|
+
files: relativeFiles,
|
|
89
|
+
generatedAt: new Date().toISOString(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Verifies a plugin's current state matches its stored checksum.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} pluginDir - Path to plugin directory
|
|
97
|
+
* @param {string} expectedChecksum - Expected SHA-256 checksum
|
|
98
|
+
* @returns {{ valid: boolean, currentChecksum: string, expectedChecksum: string }}
|
|
99
|
+
*/
|
|
100
|
+
function verifyChecksum(pluginDir, expectedChecksum) {
|
|
101
|
+
const current = generateChecksum(pluginDir);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
valid: current.checksum === expectedChecksum,
|
|
105
|
+
currentChecksum: current.checksum,
|
|
106
|
+
expectedChecksum,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Stores a checksum in the plugin's registry entry.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} projectRoot - Root directory
|
|
114
|
+
* @param {string} pluginName - Plugin name
|
|
115
|
+
* @param {string} checksum - SHA-256 checksum to store
|
|
116
|
+
* @returns {void}
|
|
117
|
+
*/
|
|
118
|
+
function storeChecksum(projectRoot, pluginName, checksum) {
|
|
119
|
+
const checksumDir = path.join(projectRoot, AGENT_DIR, ENGINE_DIR, 'plugin-checksums');
|
|
120
|
+
|
|
121
|
+
if (!fs.existsSync(checksumDir)) {
|
|
122
|
+
fs.mkdirSync(checksumDir, { recursive: true });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const checksumPath = path.join(checksumDir, `${pluginName}.sha256`);
|
|
126
|
+
fs.writeFileSync(checksumPath, checksum, 'utf-8');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Retrieves a stored checksum for a plugin.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} projectRoot - Root directory
|
|
133
|
+
* @param {string} pluginName - Plugin name
|
|
134
|
+
* @returns {string | null}
|
|
135
|
+
*/
|
|
136
|
+
function getStoredChecksum(projectRoot, pluginName) {
|
|
137
|
+
const checksumPath = path.join(projectRoot, AGENT_DIR, ENGINE_DIR, 'plugin-checksums', `${pluginName}.sha256`);
|
|
138
|
+
|
|
139
|
+
if (!fs.existsSync(checksumPath)) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return fs.readFileSync(checksumPath, 'utf-8').trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Verifies all installed plugins against their stored checksums.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} projectRoot - Root directory
|
|
150
|
+
* @returns {{ total: number, valid: number, invalid: string[], unverified: string[] }}
|
|
151
|
+
*/
|
|
152
|
+
function verifyAllPlugins(projectRoot) {
|
|
153
|
+
const pluginsDir = path.join(projectRoot, AGENT_DIR, PLUGINS_DIR);
|
|
154
|
+
|
|
155
|
+
if (!fs.existsSync(pluginsDir)) {
|
|
156
|
+
return { total: 0, valid: 0, invalid: [], unverified: [] };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const entries = fs.readdirSync(pluginsDir, { withFileTypes: true })
|
|
160
|
+
.filter((e) => e.isDirectory());
|
|
161
|
+
|
|
162
|
+
const invalid = [];
|
|
163
|
+
const unverified = [];
|
|
164
|
+
let valid = 0;
|
|
165
|
+
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
const pluginDir = path.join(pluginsDir, entry.name);
|
|
168
|
+
const storedChecksum = getStoredChecksum(projectRoot, entry.name);
|
|
169
|
+
|
|
170
|
+
if (!storedChecksum) {
|
|
171
|
+
unverified.push(entry.name);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const result = verifyChecksum(pluginDir, storedChecksum);
|
|
176
|
+
if (result.valid) {
|
|
177
|
+
valid += 1;
|
|
178
|
+
} else {
|
|
179
|
+
invalid.push(entry.name);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
total: entries.length,
|
|
185
|
+
valid,
|
|
186
|
+
invalid,
|
|
187
|
+
unverified,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
generateChecksum,
|
|
193
|
+
verifyChecksum,
|
|
194
|
+
storeChecksum,
|
|
195
|
+
getStoredChecksum,
|
|
196
|
+
verifyAllPlugins,
|
|
197
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Antigravity AI Kit — Rate Limiter
|
|
3
|
+
*
|
|
4
|
+
* Token bucket rate limiter for protecting external operations
|
|
5
|
+
* (marketplace git clones, API calls) from abuse and resource exhaustion.
|
|
6
|
+
*
|
|
7
|
+
* @module lib/rate-limiter
|
|
8
|
+
* @author Emre Dursun
|
|
9
|
+
* @since v3.2.0
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} RateLimiterOptions
|
|
16
|
+
* @property {number} [maxTokens=5] - Maximum tokens (burst capacity)
|
|
17
|
+
* @property {number} [refillRateMs=60000] - Time to refill one token (ms)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} RateLimiterState
|
|
22
|
+
* @property {string} name - Limiter name
|
|
23
|
+
* @property {number} tokens - Current available tokens
|
|
24
|
+
* @property {number} maxTokens - Maximum capacity
|
|
25
|
+
* @property {number} refillRateMs - Refill interval per token
|
|
26
|
+
* @property {number} lastRefillTime - Last refill timestamp
|
|
27
|
+
* @property {number} totalAllowed - Lifetime allowed count
|
|
28
|
+
* @property {number} totalRejected - Lifetime rejected count
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates a new rate limiter instance.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} name - Rate limiter name for identification
|
|
35
|
+
* @param {RateLimiterOptions} [options] - Configuration
|
|
36
|
+
* @returns {{ tryAcquire: Function, getState: Function, reset: Function }}
|
|
37
|
+
*/
|
|
38
|
+
function createRateLimiter(name, options = {}) {
|
|
39
|
+
const maxTokens = options.maxTokens || 5;
|
|
40
|
+
const refillRateMs = options.refillRateMs || 60000;
|
|
41
|
+
|
|
42
|
+
/** @type {RateLimiterState} */
|
|
43
|
+
const state = {
|
|
44
|
+
name,
|
|
45
|
+
tokens: maxTokens,
|
|
46
|
+
maxTokens,
|
|
47
|
+
refillRateMs,
|
|
48
|
+
lastRefillTime: Date.now(),
|
|
49
|
+
totalAllowed: 0,
|
|
50
|
+
totalRejected: 0,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Refills tokens based on elapsed time since last refill.
|
|
55
|
+
*
|
|
56
|
+
* @returns {void}
|
|
57
|
+
*/
|
|
58
|
+
function refill() {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const elapsed = now - state.lastRefillTime;
|
|
61
|
+
const tokensToAdd = Math.floor(elapsed / refillRateMs);
|
|
62
|
+
|
|
63
|
+
if (tokensToAdd > 0) {
|
|
64
|
+
state.tokens = Math.min(maxTokens, state.tokens + tokensToAdd);
|
|
65
|
+
state.lastRefillTime = now;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Attempts to acquire a token for an operation.
|
|
71
|
+
*
|
|
72
|
+
* @returns {{ allowed: boolean, retryAfterMs?: number }}
|
|
73
|
+
*/
|
|
74
|
+
function tryAcquire() {
|
|
75
|
+
refill();
|
|
76
|
+
|
|
77
|
+
if (state.tokens > 0) {
|
|
78
|
+
state.tokens -= 1;
|
|
79
|
+
state.totalAllowed += 1;
|
|
80
|
+
return { allowed: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
state.totalRejected += 1;
|
|
84
|
+
const timeSinceLastRefill = Date.now() - state.lastRefillTime;
|
|
85
|
+
const retryAfterMs = Math.max(0, refillRateMs - timeSinceLastRefill);
|
|
86
|
+
|
|
87
|
+
return { allowed: false, retryAfterMs };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Returns a snapshot of the rate limiter state.
|
|
92
|
+
*
|
|
93
|
+
* @returns {RateLimiterState}
|
|
94
|
+
*/
|
|
95
|
+
function getState() {
|
|
96
|
+
refill();
|
|
97
|
+
return { ...state };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resets the rate limiter to full capacity.
|
|
102
|
+
*
|
|
103
|
+
* @returns {void}
|
|
104
|
+
*/
|
|
105
|
+
function reset() {
|
|
106
|
+
state.tokens = maxTokens;
|
|
107
|
+
state.lastRefillTime = Date.now();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { tryAcquire, getState, reset };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { createRateLimiter };
|
package/lib/security-scanner.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
const fs = require('fs');
|
|
15
15
|
const path = require('path');
|
|
16
16
|
|
|
17
|
-
const AGENT_DIR = '
|
|
17
|
+
const { AGENT_DIR } = require('./constants');
|
|
18
18
|
|
|
19
19
|
/** Paths that are known-safe and should be excluded from injection/secret scanning */
|
|
20
20
|
const ALLOWLISTED_DIRS = ['decisions', 'engine'];
|
|
@@ -163,9 +163,6 @@ function scanForSecrets(projectRoot) {
|
|
|
163
163
|
continue;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
// Skip files in skills that mention password in example/testing contexts
|
|
167
|
-
const isSkillDoc = relativePath.includes('skills' + path.sep) || relativePath.includes('skills/');
|
|
168
|
-
|
|
169
166
|
const content = fs.readFileSync(file, 'utf-8');
|
|
170
167
|
const lines = content.split('\n');
|
|
171
168
|
|
package/lib/self-healing.js
CHANGED
|
@@ -14,15 +14,37 @@
|
|
|
14
14
|
const fs = require('fs');
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const crypto = require('crypto');
|
|
17
|
+
const { writeJsonAtomic } = require('./io');
|
|
18
|
+
const { createLogger } = require('./logger');
|
|
19
|
+
const log = createLogger('self-healing');
|
|
17
20
|
|
|
18
|
-
const AGENT_DIR = '
|
|
19
|
-
const ENGINE_DIR = 'engine';
|
|
21
|
+
const { AGENT_DIR, ENGINE_DIR } = require('./constants');
|
|
20
22
|
const HEALING_LOG_FILE = 'healing-log.json';
|
|
21
|
-
const LAST_CI_OUTPUT_FILE = 'last-ci-output.txt';
|
|
22
23
|
|
|
23
24
|
/** Maximum healing log entries before pruning */
|
|
24
25
|
const MAX_LOG_ENTRIES = 100;
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Sanitizes a module path into a valid JavaScript variable name.
|
|
29
|
+
* Handles scoped packages (@scope/name), paths with slashes,
|
|
30
|
+
* and ensures the result starts with a letter.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} modulePath - Raw module name or path
|
|
33
|
+
* @returns {string} Valid JS variable name
|
|
34
|
+
*/
|
|
35
|
+
function sanitizeVariableName(modulePath) {
|
|
36
|
+
// Extract the last segment (handle scoped packages and paths)
|
|
37
|
+
const segments = modulePath.replace('@', '').split(/[/\\]/);
|
|
38
|
+
const baseName = segments[segments.length - 1] || 'module';
|
|
39
|
+
|
|
40
|
+
// Convert to camelCase: strip non-alphanumeric, capitalize after separators
|
|
41
|
+
const sanitized = baseName
|
|
42
|
+
.replace(/[^a-zA-Z0-9]+(.)?/g, (_, chr) => (chr ? chr.toUpperCase() : ''))
|
|
43
|
+
.replace(/^[^a-zA-Z]/, '');
|
|
44
|
+
|
|
45
|
+
return sanitized || 'unknownModule';
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
/**
|
|
27
49
|
* @typedef {object} FailureDetection
|
|
28
50
|
* @property {string} type - Failure type: 'test' | 'build' | 'dependency' | 'lint'
|
|
@@ -89,20 +111,13 @@ function loadHealingLog(projectRoot) {
|
|
|
89
111
|
*/
|
|
90
112
|
function writeHealingLog(projectRoot, data) {
|
|
91
113
|
const filePath = resolveHealingLogPath(projectRoot);
|
|
92
|
-
const dir = path.dirname(filePath);
|
|
93
114
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
115
|
+
// Prune to last MAX_LOG_ENTRIES (immutable)
|
|
116
|
+
const prunedData = data.entries.length > MAX_LOG_ENTRIES
|
|
117
|
+
? { ...data, entries: data.entries.slice(-MAX_LOG_ENTRIES) }
|
|
118
|
+
: data;
|
|
97
119
|
|
|
98
|
-
|
|
99
|
-
if (data.entries.length > MAX_LOG_ENTRIES) {
|
|
100
|
-
data.entries = data.entries.slice(-MAX_LOG_ENTRIES);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const tempPath = `${filePath}.tmp`;
|
|
104
|
-
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
105
|
-
fs.renameSync(tempPath, filePath);
|
|
120
|
+
writeJsonAtomic(filePath, prunedData);
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
// ═══════════════════════════════════════════════════
|
|
@@ -292,7 +307,9 @@ function generateFixPatch(failure, diagnosis) {
|
|
|
292
307
|
// Missing import → suggest adding import
|
|
293
308
|
if (diagnosis.category === 'import') {
|
|
294
309
|
const moduleMatch = failure.message.match(/(?:Cannot find module|Module not found)[:\s]*'?([^'"\s]+)/i);
|
|
295
|
-
const
|
|
310
|
+
const rawModuleName = moduleMatch ? moduleMatch[1] : 'unknown-module';
|
|
311
|
+
// Sanitize module name to prevent injection via crafted CI output (M-11)
|
|
312
|
+
const safeModuleName = rawModuleName.replace(/[^a-zA-Z0-9@/_.-]/g, '');
|
|
296
313
|
|
|
297
314
|
return {
|
|
298
315
|
patchId,
|
|
@@ -300,7 +317,7 @@ function generateFixPatch(failure, diagnosis) {
|
|
|
300
317
|
type: 'insert',
|
|
301
318
|
line: 1,
|
|
302
319
|
original: '',
|
|
303
|
-
replacement: `const ${
|
|
320
|
+
replacement: `const ${sanitizeVariableName(safeModuleName)} = require('${safeModuleName}');`,
|
|
304
321
|
confidence: 'medium',
|
|
305
322
|
};
|
|
306
323
|
}
|
|
@@ -361,9 +378,26 @@ function applyFixWithConfirmation(projectRoot, patch, options = {}) {
|
|
|
361
378
|
if (!dryRun && patch.file !== 'unknown') {
|
|
362
379
|
const targetPath = path.join(projectRoot, patch.file);
|
|
363
380
|
|
|
381
|
+
// Validate target stays within project root (prevent path traversal via crafted CI logs)
|
|
382
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
383
|
+
const resolvedRoot = path.resolve(projectRoot);
|
|
384
|
+
if (!resolvedTarget.startsWith(resolvedRoot + path.sep) && resolvedTarget !== resolvedRoot) {
|
|
385
|
+
logEntry.applied = false;
|
|
386
|
+
// Load healing log and record the blocked attempt (H-6: fixed variable shadowing)
|
|
387
|
+
const healingLog = loadHealingLog(projectRoot);
|
|
388
|
+
writeHealingLog(projectRoot, { ...healingLog, entries: [...healingLog.entries, logEntry] });
|
|
389
|
+
return { applied: false, preview, patchId: patch.patchId };
|
|
390
|
+
}
|
|
391
|
+
|
|
364
392
|
if (fs.existsSync(targetPath)) {
|
|
365
393
|
try {
|
|
366
394
|
const content = fs.readFileSync(targetPath, 'utf-8');
|
|
395
|
+
|
|
396
|
+
// Create backup before applying patch
|
|
397
|
+
const backupPath = `${targetPath}.bak`;
|
|
398
|
+
fs.writeFileSync(backupPath, content, 'utf-8');
|
|
399
|
+
logEntry.rollbackData.backupPath = backupPath;
|
|
400
|
+
|
|
367
401
|
const lines = content.split('\n');
|
|
368
402
|
|
|
369
403
|
if (patch.type === 'insert' && patch.line !== null) {
|
|
@@ -392,10 +426,9 @@ function applyFixWithConfirmation(projectRoot, patch, options = {}) {
|
|
|
392
426
|
}
|
|
393
427
|
}
|
|
394
428
|
|
|
395
|
-
// Log the action
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
writeHealingLog(projectRoot, log);
|
|
429
|
+
// Log the action (using distinct variable name to avoid shadowing logger)
|
|
430
|
+
const healingLog = loadHealingLog(projectRoot);
|
|
431
|
+
writeHealingLog(projectRoot, { ...healingLog, entries: [...healingLog.entries, logEntry] });
|
|
399
432
|
|
|
400
433
|
return {
|
|
401
434
|
applied: logEntry.applied,
|
|
@@ -414,12 +447,13 @@ function getHealingReport(projectRoot) {
|
|
|
414
447
|
const log = loadHealingLog(projectRoot);
|
|
415
448
|
const entries = log.entries || [];
|
|
416
449
|
|
|
417
|
-
const
|
|
418
|
-
const
|
|
450
|
+
const appliedEntries = entries.filter((e) => !e.dryRun);
|
|
451
|
+
const applied = appliedEntries.filter((e) => e.applied).length;
|
|
452
|
+
const dryRuns = entries.filter((e) => e.dryRun).length;
|
|
419
453
|
|
|
420
454
|
return {
|
|
421
455
|
totalHeals: entries.length,
|
|
422
|
-
successRate:
|
|
456
|
+
successRate: appliedEntries.length > 0 ? Math.round((applied / appliedEntries.length) * 100) : 0,
|
|
423
457
|
recentEntries: entries.slice(-5),
|
|
424
458
|
pendingPatches: dryRuns,
|
|
425
459
|
};
|