agent-security-scanner-mcp 3.16.1 ā 3.17.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/README.md +20 -1
- package/index.js +62 -43
- package/package.json +1 -1
- package/src/daemon-client.js +52 -5
- package/src/tools/scan-prompt.js +31 -2
- package/src/tools/scan-skill.js +49 -47
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="./prooflayer-
|
|
3
|
+
<img src="./prooflayer-logo.png" alt="ProofLayer Logo" width="400"/>
|
|
4
4
|
|
|
5
5
|
# agent-security-scanner-mcp
|
|
6
6
|
|
|
@@ -1161,6 +1161,25 @@ All MCP tools support a `verbosity` parameter to minimize context window consump
|
|
|
1161
1161
|
|
|
1162
1162
|
## Changelog
|
|
1163
1163
|
|
|
1164
|
+
### v3.17.0 (2026-03-04) - Critical Security Fixes
|
|
1165
|
+
|
|
1166
|
+
**š“ 6 CRITICAL vulnerabilities fixed | š” 4 IMPORTANT issues resolved**
|
|
1167
|
+
|
|
1168
|
+
- **CVE GHSA-345p-7cg4-v4c7**: Fixed MCP SDK cross-client data leak (CVSS 7.1) - updated to @modelcontextprotocol/sdk@1.27.1
|
|
1169
|
+
- **ReDoS Protection**: Added regex timeouts (1s), size limits (500KB), and iteration caps (100) in prompt scanner
|
|
1170
|
+
- **Path Traversal Fix**: Resolved TOCTOU symlink attacks using `realpathSync()` before validation
|
|
1171
|
+
- **Race Condition Fix**: Prevented multiple daemon spawns from concurrent requests
|
|
1172
|
+
- **Promise Rejection Handling**: Wrapped CLI commands in async IIFE to prevent hangs
|
|
1173
|
+
- **Temp File Security**: Fixed symlink attacks with `mkdtempSync()` and restrictive permissions (0600)
|
|
1174
|
+
- **Daemon Orphaning**: Added SIGKILL fallback with 5s timeout for graceful shutdown
|
|
1175
|
+
- **Dependency Updates**: Fixed ajv, hono, and qs vulnerabilities via `npm audit fix`
|
|
1176
|
+
|
|
1177
|
+
**Impact:** npm audit 4ā0 vulnerabilities | Security Grade DāB | Test coverage 99.76% (419/420)
|
|
1178
|
+
|
|
1179
|
+
š See [docs/release-notes/SECURITY-FIXES-v3.17.0.md](docs/release-notes/SECURITY-FIXES-v3.17.0.md) for technical details
|
|
1180
|
+
|
|
1181
|
+
---
|
|
1182
|
+
|
|
1164
1183
|
### v3.10.0
|
|
1165
1184
|
- **`scan_skill` Tool** ā 6-layer deep security scanner for OpenClaw skills: prompt injection (59+ rules), AST+taint code analysis, ClawHavoc malware signatures, package supply chain verification, and SHA-256 rug pull detection. Returns A-F grade with hard-fail on ClawHavoc/rug pull/critical findings
|
|
1166
1185
|
- **ClawHavoc Signature Database** (`rules/clawhavoc.yaml`) ā 27 rules, 121 regex patterns across 10 threat categories (reverse shells, crypto miners, info stealers, keyloggers, screen capture, DNS exfiltration, C2 beacons, OpenClaw-specific attacks, campaign patterns, exfil endpoints), mapped to MITRE ATT&CK
|
package/index.js
CHANGED
|
@@ -238,51 +238,70 @@ server.tool(
|
|
|
238
238
|
// See src/cli/init.js, src/cli/doctor.js, src/cli/demo.js
|
|
239
239
|
|
|
240
240
|
// Handle CLI arguments before loading heavy package data
|
|
241
|
+
// Security: Wrap in async IIFE to prevent unhandled promise rejections and race conditions
|
|
241
242
|
const cliArgs = process.argv.slice(2);
|
|
242
|
-
if (cliArgs[0] === 'init') {
|
|
243
|
-
runInit(cliArgs.slice(1)).then(() => process.exit(0)).catch((err) => {
|
|
244
|
-
console.error(` Error: ${err.message}\n`);
|
|
245
|
-
process.exit(1);
|
|
246
|
-
});
|
|
247
|
-
} else if (cliArgs[0] === 'doctor') {
|
|
248
|
-
runDoctor(cliArgs.slice(1)).then(() => process.exit(0)).catch((err) => {
|
|
249
|
-
console.error(` Error: ${err.message}\n`);
|
|
250
|
-
process.exit(1);
|
|
251
|
-
});
|
|
252
|
-
} else if (cliArgs[0] === 'demo') {
|
|
253
|
-
runDemo(cliArgs.slice(1)).then(() => process.exit(0)).catch((err) => {
|
|
254
|
-
console.error(` Error: ${err.message}\n`);
|
|
255
|
-
process.exit(1);
|
|
256
|
-
});
|
|
257
|
-
} else if (cliArgs[0] === 'init-hooks') {
|
|
258
|
-
runInitHooks(cliArgs.slice(1)).then(() => process.exit(0)).catch((err) => {
|
|
259
|
-
console.error(` Error: ${err.message}\n`);
|
|
260
|
-
process.exit(1);
|
|
261
|
-
});
|
|
262
|
-
} else if (cliArgs[0] === 'report') {
|
|
263
|
-
runReport(cliArgs.slice(1)).then(() => process.exit(0)).catch((err) => {
|
|
264
|
-
console.error(` Error: ${err.message}\n`);
|
|
265
|
-
process.exit(1);
|
|
266
|
-
});
|
|
267
|
-
} else if (cliArgs[0] === 'scan-prompt') {
|
|
268
|
-
// CLI mode: scan-prompt <text> [--verbosity minimal|compact|full]
|
|
269
|
-
const text = cliArgs[1];
|
|
270
|
-
if (!text) {
|
|
271
|
-
console.error('Usage: agent-security-scanner-mcp scan-prompt <text> [--verbosity minimal|compact|full]');
|
|
272
|
-
process.exit(1);
|
|
273
|
-
}
|
|
274
|
-
const verbosityIdx = cliArgs.indexOf('--verbosity');
|
|
275
|
-
const verbosity = verbosityIdx !== -1 ? cliArgs[verbosityIdx + 1] : 'compact';
|
|
276
243
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
244
|
+
(async () => {
|
|
245
|
+
if (cliArgs[0] === 'init') {
|
|
246
|
+
try {
|
|
247
|
+
await runInit(cliArgs.slice(1));
|
|
248
|
+
process.exit(0);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
console.error(` Error: ${err.message}\n`);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
} else if (cliArgs[0] === 'doctor') {
|
|
254
|
+
try {
|
|
255
|
+
await runDoctor(cliArgs.slice(1));
|
|
256
|
+
process.exit(0);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
console.error(` Error: ${err.message}\n`);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
} else if (cliArgs[0] === 'demo') {
|
|
262
|
+
try {
|
|
263
|
+
await runDemo(cliArgs.slice(1));
|
|
264
|
+
process.exit(0);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
console.error(` Error: ${err.message}\n`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
} else if (cliArgs[0] === 'init-hooks') {
|
|
270
|
+
try {
|
|
271
|
+
await runInitHooks(cliArgs.slice(1));
|
|
272
|
+
process.exit(0);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.error(` Error: ${err.message}\n`);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
} else if (cliArgs[0] === 'report') {
|
|
278
|
+
try {
|
|
279
|
+
await runReport(cliArgs.slice(1));
|
|
280
|
+
process.exit(0);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.error(` Error: ${err.message}\n`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
} else if (cliArgs[0] === 'scan-prompt') {
|
|
286
|
+
// CLI mode: scan-prompt <text> [--verbosity minimal|compact|full]
|
|
287
|
+
const text = cliArgs[1];
|
|
288
|
+
if (!text) {
|
|
289
|
+
console.error('Usage: agent-security-scanner-mcp scan-prompt <text> [--verbosity minimal|compact|full]');
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
const verbosityIdx = cliArgs.indexOf('--verbosity');
|
|
293
|
+
const verbosity = verbosityIdx !== -1 ? cliArgs[verbosityIdx + 1] : 'compact';
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
loadPackageLists();
|
|
297
|
+
const result = await scanAgentPrompt({ prompt_text: text, verbosity });
|
|
298
|
+
const output = JSON.parse(result.content[0].text);
|
|
299
|
+
console.log(JSON.stringify(output, null, 2));
|
|
300
|
+
process.exit(output.action === 'BLOCK' ? 1 : 0);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
console.error(JSON.stringify({ error: err.message }));
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
286
305
|
} else if (cliArgs[0] === 'scan-security') {
|
|
287
306
|
// CLI mode: scan-security <file> [--verbosity minimal|compact|full] [--format json|sarif]
|
|
288
307
|
const filePath = cliArgs[1];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-security-scanner-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.17.0",
|
|
4
4
|
"mcpName": "io.github.sinewaveai/agent-security-scanner-mcp",
|
|
5
5
|
"description": "Security scanner MCP server for AI coding agents. Prompt injection firewall, package hallucination detection (4.3M+ packages), 1000+ vulnerability rules with AST & taint analysis, auto-fix. For Claude Code, Cursor, Windsurf, Cline, OpenClaw.",
|
|
6
6
|
"main": "index.js",
|
package/src/daemon-client.js
CHANGED
|
@@ -40,11 +40,24 @@ class DaemonClient {
|
|
|
40
40
|
async ensureRunning() {
|
|
41
41
|
if (this._dead) throw new Error('Daemon permanently unavailable');
|
|
42
42
|
if (this._proc && !this._proc.killed && this._proc.exitCode === null) return;
|
|
43
|
+
|
|
44
|
+
// Security: Fix race condition - create promise synchronously before any await
|
|
43
45
|
if (this._starting) return this._starting;
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
// Create promise SYNCHRONOUSLY to prevent race window
|
|
48
|
+
const spawnPromise = (async () => {
|
|
49
|
+
try {
|
|
50
|
+
await this._spawn();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
this._starting = null; // Clear on error
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
|
|
57
|
+
this._starting = spawnPromise;
|
|
58
|
+
|
|
46
59
|
try {
|
|
47
|
-
await
|
|
60
|
+
await spawnPromise;
|
|
48
61
|
} finally {
|
|
49
62
|
this._starting = null;
|
|
50
63
|
}
|
|
@@ -218,12 +231,46 @@ class DaemonClient {
|
|
|
218
231
|
|
|
219
232
|
async shutdown() {
|
|
220
233
|
if (!this._proc || this._proc.killed || this._proc.exitCode !== null) return;
|
|
234
|
+
|
|
235
|
+
// Security: Fix process orphaning with timeout and SIGKILL fallback
|
|
236
|
+
// Try graceful shutdown first (5 second timeout)
|
|
221
237
|
try {
|
|
222
|
-
await
|
|
223
|
-
|
|
224
|
-
|
|
238
|
+
await Promise.race([
|
|
239
|
+
this._send({ action: 'shutdown' }),
|
|
240
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Shutdown timeout')), 5000))
|
|
241
|
+
]);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.warn(`Graceful daemon shutdown failed: ${err.message}. Force-killing.`);
|
|
225
244
|
}
|
|
245
|
+
|
|
246
|
+
// Force kill if still running
|
|
247
|
+
if (this._proc && !this._proc.killed && this._proc.exitCode === null) {
|
|
248
|
+
try {
|
|
249
|
+
this._proc.kill('SIGKILL');
|
|
250
|
+
} catch (err) {
|
|
251
|
+
console.error(`Failed to kill daemon process:`, err.message);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
226
255
|
this._cleanup();
|
|
256
|
+
|
|
257
|
+
// Wait for process to actually exit (with timeout)
|
|
258
|
+
if (this._proc) {
|
|
259
|
+
await new Promise(resolve => {
|
|
260
|
+
const checkInterval = setInterval(() => {
|
|
261
|
+
if (!this._proc || this._proc.exitCode !== null) {
|
|
262
|
+
clearInterval(checkInterval);
|
|
263
|
+
resolve();
|
|
264
|
+
}
|
|
265
|
+
}, 100);
|
|
266
|
+
|
|
267
|
+
// Timeout after 2 seconds
|
|
268
|
+
setTimeout(() => {
|
|
269
|
+
clearInterval(checkInterval);
|
|
270
|
+
resolve();
|
|
271
|
+
}, 2000);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
227
274
|
}
|
|
228
275
|
}
|
|
229
276
|
|
package/src/tools/scan-prompt.js
CHANGED
|
@@ -484,12 +484,29 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
|
|
|
484
484
|
const allRules = [...agentRules, ...promptRules, ...openclawRules];
|
|
485
485
|
|
|
486
486
|
// 2.7: Extract content from code blocks (``` and ~~~) and append to scan text
|
|
487
|
+
// Security: Add size limits and iteration caps to prevent ReDoS
|
|
488
|
+
const EXPANDED_TEXT_MAX = 500 * 1024; // 500KB absolute max
|
|
489
|
+
const MAX_CODE_BLOCK_ITERATIONS = 100;
|
|
490
|
+
const MAX_SINGLE_BLOCK_SIZE = 10000; // 10KB per code block
|
|
491
|
+
|
|
487
492
|
let expandedText = prompt_text;
|
|
488
493
|
const codeBlockRegex = /(`{3,})([\s\S]*?)\1|(~{3,})([\s\S]*?)\3/g;
|
|
489
494
|
let codeBlockMatch;
|
|
495
|
+
let iterations = 0;
|
|
496
|
+
|
|
490
497
|
while ((codeBlockMatch = codeBlockRegex.exec(prompt_text)) !== null) {
|
|
498
|
+
if (++iterations > MAX_CODE_BLOCK_ITERATIONS) {
|
|
499
|
+
console.warn('Code block extraction iteration limit reached');
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
if (expandedText.length > EXPANDED_TEXT_MAX) {
|
|
503
|
+
console.warn('Expanded text size limit reached');
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
|
|
491
507
|
// Group 2 = content inside backtick fences, Group 4 = content inside tilde fences
|
|
492
508
|
const inner = (codeBlockMatch[2] || codeBlockMatch[4] || '')
|
|
509
|
+
.substring(0, MAX_SINGLE_BLOCK_SIZE) // Cap individual block size
|
|
493
510
|
.replace(/^\w*\n?/, ''); // strip optional language tag
|
|
494
511
|
expandedText += '\n' + inner;
|
|
495
512
|
}
|
|
@@ -531,9 +548,10 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
|
|
|
531
548
|
}
|
|
532
549
|
|
|
533
550
|
// 2.7d: Strip Zalgo diacritics ā NFKD decompose first, then strip combining marks
|
|
551
|
+
// Security: Use Unicode property escapes to catch ALL combining marks (fixes bypass)
|
|
534
552
|
const nfkd = expandedText.normalize('NFKD');
|
|
535
|
-
const zalgoStripped = nfkd.replace(/
|
|
536
|
-
if (zalgoStripped !== expandedText) {
|
|
553
|
+
const zalgoStripped = nfkd.replace(/\p{Mn}/gu, ''); // All Mark-Nonspacing combining characters
|
|
554
|
+
if (zalgoStripped !== expandedText && expandedText.length < EXPANDED_TEXT_MAX) {
|
|
537
555
|
expandedText += '\n' + zalgoStripped;
|
|
538
556
|
}
|
|
539
557
|
|
|
@@ -562,12 +580,22 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
|
|
|
562
580
|
}
|
|
563
581
|
|
|
564
582
|
// Scan expanded text against all rules
|
|
583
|
+
// Security: Add timeout protection for regex matching
|
|
584
|
+
const REGEX_TIMEOUT_MS = 1000;
|
|
585
|
+
|
|
565
586
|
for (const rule of allRules) {
|
|
566
587
|
for (const pattern of rule.patterns) {
|
|
567
588
|
try {
|
|
568
589
|
const regex = new RegExp(pattern, 'i');
|
|
590
|
+
const startTime = Date.now();
|
|
569
591
|
const match = expandedText.match(regex);
|
|
570
592
|
|
|
593
|
+
// Check for regex timeout (ReDoS protection)
|
|
594
|
+
if (Date.now() - startTime > REGEX_TIMEOUT_MS) {
|
|
595
|
+
console.warn(`Regex timeout for rule ${rule.id}, skipping`);
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
|
|
571
599
|
if (match) {
|
|
572
600
|
findings.push({
|
|
573
601
|
rule_id: rule.id,
|
|
@@ -583,6 +611,7 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
|
|
|
583
611
|
}
|
|
584
612
|
} catch (e) {
|
|
585
613
|
// Skip invalid regex
|
|
614
|
+
console.warn(`Regex error for rule ${rule.id}:`, e.message);
|
|
586
615
|
}
|
|
587
616
|
}
|
|
588
617
|
}
|
package/src/tools/scan-skill.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// supply chain verification, and rug pull detection.
|
|
4
4
|
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import { existsSync, readFileSync, readdirSync, statSync, lstatSync, realpathSync, writeFileSync, mkdirSync, unlinkSync, renameSync, chmodSync } from "fs";
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, statSync, lstatSync, realpathSync, writeFileSync, mkdirSync, unlinkSync, renameSync, chmodSync, mkdtempSync, rmSync } from "fs";
|
|
7
7
|
import { resolve, basename, dirname, extname, join, sep } from "path";
|
|
8
8
|
import { createHash } from "crypto";
|
|
9
9
|
import { tmpdir, homedir } from "os";
|
|
@@ -329,11 +329,20 @@ async function runCodeBlockScan(blocks, signal) {
|
|
|
329
329
|
const ext = LANG_EXT_MAP[lang];
|
|
330
330
|
if (!ext) continue;
|
|
331
331
|
|
|
332
|
-
|
|
333
|
-
|
|
332
|
+
// Security: Create unique temp directory to prevent race conditions and symlink attacks
|
|
333
|
+
let tmpDir;
|
|
334
|
+
try {
|
|
335
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'skill-scan-'));
|
|
336
|
+
} catch (err) {
|
|
337
|
+
console.error(`Failed to create temp directory: ${err.message}`);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const tmpPath = join(tmpDir, `code.${ext}`);
|
|
334
342
|
|
|
335
343
|
try {
|
|
336
|
-
|
|
344
|
+
// Write with restrictive permissions (0600) to prevent other users from reading
|
|
345
|
+
writeFileSync(tmpPath, code, { encoding: 'utf-8', mode: 0o600 });
|
|
337
346
|
const issues = await runAnalyzerAsync(tmpPath, 'auto', signal);
|
|
338
347
|
if (Array.isArray(issues)) {
|
|
339
348
|
for (const issue of issues) {
|
|
@@ -350,7 +359,12 @@ async function runCodeBlockScan(blocks, signal) {
|
|
|
350
359
|
}
|
|
351
360
|
}
|
|
352
361
|
} finally {
|
|
353
|
-
|
|
362
|
+
// Clean up entire directory atomically
|
|
363
|
+
try {
|
|
364
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
365
|
+
} catch (err) {
|
|
366
|
+
console.error(`Failed to clean up temp dir ${tmpDir}:`, err.message);
|
|
367
|
+
}
|
|
354
368
|
}
|
|
355
369
|
} catch (error) {
|
|
356
370
|
console.error(`Layer 2 (code block scan) failed for ${lang}:`, error.message);
|
|
@@ -878,64 +892,52 @@ function generateRecommendation(grade) {
|
|
|
878
892
|
// ---------------------------------------------------------------------------
|
|
879
893
|
|
|
880
894
|
export async function scanSkill({ skill_path, verbosity, baseline }) {
|
|
881
|
-
//
|
|
882
|
-
const
|
|
895
|
+
// Security: Resolve to canonical path FIRST to prevent TOCTOU and symlink attacks
|
|
896
|
+
const inputPath = skill_path;
|
|
897
|
+
let realPath;
|
|
883
898
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
const allowedSkillRoots = [
|
|
889
|
-
resolve(homedir(), '.openclaw', 'skills'),
|
|
890
|
-
resolve(homedir(), '.openclaw', 'workspace', 'skills'),
|
|
891
|
-
];
|
|
892
|
-
const isAllowed = pathStartsWith(resolvedPath, rawCwd)
|
|
893
|
-
|| allowedSkillRoots.some(root => pathStartsWith(resolvedPath, root));
|
|
894
|
-
if (!isAllowed) {
|
|
899
|
+
try {
|
|
900
|
+
// Resolve to canonical path immediately (defeats symlink attacks)
|
|
901
|
+
realPath = realpathSync(resolve(inputPath));
|
|
902
|
+
} catch (err) {
|
|
895
903
|
return {
|
|
896
904
|
content: [{ type: "text", text: JSON.stringify({
|
|
897
|
-
error: "
|
|
898
|
-
skill_path:
|
|
905
|
+
error: "Invalid path, symlink loop, or permission denied",
|
|
906
|
+
skill_path: inputPath,
|
|
907
|
+
details: err.message
|
|
899
908
|
}) }]
|
|
900
909
|
};
|
|
901
910
|
}
|
|
902
911
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
912
|
+
// Verify containment on canonical path ONLY
|
|
913
|
+
// This prevents symlink escapes by checking the REAL resolved location
|
|
914
|
+
const canonCwd = realpathSync(process.cwd());
|
|
915
|
+
const allowedSkillRoots = [
|
|
916
|
+
resolve(homedir(), '.openclaw', 'skills'),
|
|
917
|
+
resolve(homedir(), '.openclaw', 'workspace', 'skills'),
|
|
918
|
+
].map(root => {
|
|
919
|
+
try {
|
|
920
|
+
return existsSync(root) ? realpathSync(root) : null;
|
|
921
|
+
} catch {
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
}).filter(Boolean);
|
|
908
925
|
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
if (topStat.isSymbolicLink()) {
|
|
912
|
-
return {
|
|
913
|
-
content: [{ type: "text", text: JSON.stringify({
|
|
914
|
-
error: "Symbolic links are not allowed as skill_path ā resolve the real path first",
|
|
915
|
-
skill_path: resolvedPath
|
|
916
|
-
}) }]
|
|
917
|
-
};
|
|
918
|
-
}
|
|
926
|
+
const isAllowed = pathStartsWith(realPath, canonCwd)
|
|
927
|
+
|| allowedSkillRoots.some(root => pathStartsWith(realPath, root));
|
|
919
928
|
|
|
920
|
-
|
|
921
|
-
// Use canonical cwd here since realPath is also canonical.
|
|
922
|
-
const realPath = realpathSync(resolvedPath);
|
|
923
|
-
let canonCwd;
|
|
924
|
-
try { canonCwd = realpathSync(rawCwd); } catch { canonCwd = rawCwd; }
|
|
925
|
-
const canonRoots = allowedSkillRoots.map(root => {
|
|
926
|
-
try { return realpathSync(root); } catch { return root; }
|
|
927
|
-
});
|
|
928
|
-
const realAllowed = pathStartsWith(realPath, canonCwd)
|
|
929
|
-
|| canonRoots.some(root => pathStartsWith(realPath, root));
|
|
930
|
-
if (!realAllowed) {
|
|
929
|
+
if (!isAllowed) {
|
|
931
930
|
return {
|
|
932
931
|
content: [{ type: "text", text: JSON.stringify({
|
|
933
932
|
error: "skill_path must be within the current working directory or ~/.openclaw/skills/ (or ~/.openclaw/workspace/skills/)",
|
|
934
|
-
skill_path: realPath
|
|
933
|
+
skill_path: realPath,
|
|
934
|
+
attempted_path: inputPath
|
|
935
935
|
}) }]
|
|
936
936
|
};
|
|
937
937
|
}
|
|
938
938
|
|
|
939
|
+
// Path is now safe - realPath is canonical and within allowed boundaries
|
|
940
|
+
|
|
939
941
|
const stat = statSync(realPath);
|
|
940
942
|
let skillDir, skillFile;
|
|
941
943
|
|