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.
Files changed (43) hide show
  1. package/.agent/agents/planner.md +205 -62
  2. package/.agent/contexts/plan-quality-log.md +30 -0
  3. package/.agent/engine/loading-rules.json +37 -3
  4. package/.agent/hooks/hooks.json +10 -0
  5. package/.agent/manifest.json +4 -3
  6. package/.agent/skills/plan-validation/SKILL.md +192 -0
  7. package/.agent/skills/plan-writing/SKILL.md +47 -8
  8. package/.agent/skills/plan-writing/domain-enhancers.md +114 -0
  9. package/.agent/skills/plan-writing/plan-retrospective.md +116 -0
  10. package/.agent/skills/plan-writing/plan-schema.md +119 -0
  11. package/.agent/workflows/plan.md +49 -5
  12. package/README.md +30 -29
  13. package/bin/ag-kit.js +26 -5
  14. package/lib/agent-registry.js +17 -3
  15. package/lib/agent-reputation.js +3 -11
  16. package/lib/circuit-breaker.js +195 -0
  17. package/lib/cli-commands.js +88 -1
  18. package/lib/config-validator.js +274 -0
  19. package/lib/conflict-detector.js +29 -22
  20. package/lib/constants.js +35 -0
  21. package/lib/engineering-manager.js +9 -27
  22. package/lib/error-budget.js +105 -29
  23. package/lib/hook-system.js +8 -4
  24. package/lib/identity.js +22 -27
  25. package/lib/io.js +74 -0
  26. package/lib/loading-engine.js +248 -35
  27. package/lib/logger.js +118 -0
  28. package/lib/marketplace.js +43 -20
  29. package/lib/plugin-system.js +55 -31
  30. package/lib/plugin-verifier.js +197 -0
  31. package/lib/rate-limiter.js +113 -0
  32. package/lib/security-scanner.js +1 -4
  33. package/lib/self-healing.js +58 -24
  34. package/lib/session-manager.js +51 -48
  35. package/lib/skill-sandbox.js +1 -1
  36. package/lib/task-governance.js +10 -11
  37. package/lib/task-model.js +42 -27
  38. package/lib/updater.js +1 -1
  39. package/lib/verify.js +4 -4
  40. package/lib/workflow-engine.js +88 -68
  41. package/lib/workflow-events.js +166 -0
  42. package/lib/workflow-persistence.js +19 -19
  43. package/package.json +2 -2
@@ -15,11 +15,11 @@
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
17
 
18
- const AGENT_DIR = '.agent';
19
- const ENGINE_DIR = 'engine';
20
- const PLUGINS_DIR = 'plugins';
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
- const tempPath = `${registryPath}.tmp`;
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
- const tempPath = `${hooksPath}.tmp`;
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
- const tempPath = `${hooksPath}.tmp`;
518
- fs.writeFileSync(tempPath, JSON.stringify(hooksConfig, null, 2) + '\n', 'utf-8');
519
- fs.renameSync(tempPath, hooksPath);
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
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
555
+ let config;
556
+ try {
557
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
558
+ } catch {
559
+ continue;
560
+ }
539
561
 
540
- // Shallow merge patches
562
+ // Deep merge patches with recursive prototype pollution guard (H-5)
541
563
  for (const [key, value] of Object.entries(patches)) {
542
- config[key] = value;
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
- if (!config._pluginPatches) {
547
- config._pluginPatches = {};
548
- }
549
- config._pluginPatches[pluginName] = Object.keys(patches);
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
- const tempPath = `${configPath}.tmp`;
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 };
@@ -14,7 +14,7 @@
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
16
 
17
- const AGENT_DIR = '.agent';
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
 
@@ -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 = '.agent';
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
- if (!fs.existsSync(dir)) {
95
- fs.mkdirSync(dir, { recursive: true });
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
- // Prune to last MAX_LOG_ENTRIES
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 moduleName = moduleMatch ? moduleMatch[1] : 'unknown-module';
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 ${moduleName.replace(/[^a-zA-Z]/g, '')} = require('${moduleName}');`,
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 log = loadHealingLog(projectRoot);
397
- log.entries.push(logEntry);
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 applied = entries.filter((e) => e.applied).length;
418
- const dryRuns = entries.filter((e) => e.dryRun && !e.applied).length;
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: entries.length > 0 ? Math.round((applied / entries.length) * 100) : 0,
456
+ successRate: appliedEntries.length > 0 ? Math.round((applied / appliedEntries.length) * 100) : 0,
423
457
  recentEntries: entries.slice(-5),
424
458
  pendingPatches: dryRuns,
425
459
  };