agent-security-scanner-mcp 3.17.2 → 3.19.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/analyzer.py +26 -2
- package/cross_file_analyzer.py +478 -5
- package/package.json +3 -2
- package/python_taint_fallback.py +688 -0
- package/rules/__init__.py +42 -3
- package/rules/prompt-injection.security.yaml +4 -4
- package/rules/semantic-security.yaml +679 -0
- package/src/fix-patterns.js +9 -9
- package/src/history.js +1 -1
- package/src/tools/check-package.js +15 -0
- package/src/tools/scan-prompt.js +44 -31
- package/src/tools/scan-security.js +33 -4
- package/src/tools/scan-skill.js +54 -22
package/src/fix-patterns.js
CHANGED
|
@@ -20,7 +20,7 @@ export const FIX_TEMPLATES = {
|
|
|
20
20
|
// ===========================================
|
|
21
21
|
"sql-injection": {
|
|
22
22
|
description: "Use parameterized queries instead of string concatenation",
|
|
23
|
-
fix: (line) =>
|
|
23
|
+
fix: (line) => '// TODO: manual fix required — use parameterized queries instead of string concatenation\n// ' + line.trim()
|
|
24
24
|
},
|
|
25
25
|
"nosql-injection": {
|
|
26
26
|
description: "Sanitize MongoDB query inputs",
|
|
@@ -28,7 +28,7 @@ export const FIX_TEMPLATES = {
|
|
|
28
28
|
},
|
|
29
29
|
"raw-query": {
|
|
30
30
|
description: "Use parameterized queries instead of raw SQL",
|
|
31
|
-
fix: (line) =>
|
|
31
|
+
fix: (line) => '// TODO: manual fix required — use parameterized queries instead of raw SQL\n// ' + line.trim()
|
|
32
32
|
},
|
|
33
33
|
|
|
34
34
|
// ===========================================
|
|
@@ -306,10 +306,10 @@ export const FIX_TEMPLATES = {
|
|
|
306
306
|
"path-traversal": {
|
|
307
307
|
description: "Resolve real path and validate prefix to prevent traversal",
|
|
308
308
|
fix: (line, lang) => {
|
|
309
|
-
if (lang === 'python') return
|
|
310
|
-
if (lang === 'go') return
|
|
311
|
-
if (lang === 'java') return
|
|
312
|
-
return
|
|
309
|
+
if (lang === 'python') return '# TODO: manual fix required — use os.path.realpath() and validate the prefix\n# ' + line.trim();
|
|
310
|
+
if (lang === 'go') return '// TODO: manual fix required — use filepath.Clean() and validate the prefix\n// ' + line.trim();
|
|
311
|
+
if (lang === 'java') return '// TODO: manual fix required — use getCanonicalFile() and validate the prefix\n// ' + line.trim();
|
|
312
|
+
return '// TODO: manual fix required — use path.resolve() and validate the prefix\n// ' + line.trim();
|
|
313
313
|
}
|
|
314
314
|
},
|
|
315
315
|
|
|
@@ -418,7 +418,7 @@ export const FIX_TEMPLATES = {
|
|
|
418
418
|
// ===========================================
|
|
419
419
|
"xpath-injection": {
|
|
420
420
|
description: "Use parameterized XPath queries",
|
|
421
|
-
fix: (line) =>
|
|
421
|
+
fix: (line) => '// TODO: manual fix required — use parameterized XPath queries instead of concatenation\n// ' + line.trim()
|
|
422
422
|
},
|
|
423
423
|
|
|
424
424
|
// ===========================================
|
|
@@ -695,9 +695,9 @@ export const FIX_TEMPLATES = {
|
|
|
695
695
|
description: "CRITICAL: Never eval() LLM responses - use JSON parsing or ast.literal_eval for safe subset",
|
|
696
696
|
fix: (line, lang) => {
|
|
697
697
|
if (lang === 'python') {
|
|
698
|
-
return line.replace(/eval\s*\(\s*(\w+)/, 'ast.literal_eval($1 # SECURITY: Use safe parsing only');
|
|
698
|
+
return line.replace(/eval\s*\(\s*(\w+)\s*\)/, 'ast.literal_eval($1) # SECURITY: Use safe parsing only');
|
|
699
699
|
}
|
|
700
|
-
return line.replace(/eval\s*\(\s*(\w+)/, 'JSON.parse($1 /* SECURITY: Use safe JSON parsing */');
|
|
700
|
+
return line.replace(/eval\s*\(\s*(\w+)\s*\)/, 'JSON.parse($1) /* SECURITY: Use safe JSON parsing */');
|
|
701
701
|
}
|
|
702
702
|
},
|
|
703
703
|
"exec-llm-response": {
|
package/src/history.js
CHANGED
|
@@ -32,6 +32,17 @@ const BLOOM_FILTERS = {
|
|
|
32
32
|
rubygems: null
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
// Flutter/Dart SDK packages are legitimate dependencies even though they do
|
|
36
|
+
// not appear in the pub.dev package dump used for the text-based lookup.
|
|
37
|
+
const DART_SDK_PACKAGES = new Set([
|
|
38
|
+
'flutter',
|
|
39
|
+
'flutter_test',
|
|
40
|
+
'flutter_driver',
|
|
41
|
+
'flutter_localizations',
|
|
42
|
+
'flutter_web_plugins',
|
|
43
|
+
'integration_test',
|
|
44
|
+
]);
|
|
45
|
+
|
|
35
46
|
// Load package lists on startup
|
|
36
47
|
export function loadPackageLists() {
|
|
37
48
|
const packagesDir = join(__dirname, '..', '..', 'packages');
|
|
@@ -67,6 +78,10 @@ export function loadPackageLists() {
|
|
|
67
78
|
|
|
68
79
|
// Check if a package is hallucinated
|
|
69
80
|
export function isHallucinated(packageName, ecosystem) {
|
|
81
|
+
if (ecosystem === 'dart' && DART_SDK_PACKAGES.has(packageName)) {
|
|
82
|
+
return { hallucinated: false, sdkPackage: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
const legitPackages = LEGITIMATE_PACKAGES[ecosystem];
|
|
71
86
|
|
|
72
87
|
// First check Set-based lookup (exact match)
|
package/src/tools/scan-prompt.js
CHANGED
|
@@ -58,11 +58,41 @@ const CONFIDENCE_MULTIPLIERS = {
|
|
|
58
58
|
// Maximum prompt size to prevent DoS via large inputs (100KB)
|
|
59
59
|
const MAX_PROMPT_SIZE = 100 * 1024;
|
|
60
60
|
|
|
61
|
+
// Maximum text length fed to any single regex to prevent ReDoS.
|
|
62
|
+
// Prompt-injection patterns look for short markers/phrases, so scanning
|
|
63
|
+
// overlapping 2 KB windows covers all realistic payloads while keeping
|
|
64
|
+
// worst-case regex time bounded.
|
|
65
|
+
const REGEX_SCAN_WINDOW = 2048;
|
|
66
|
+
const REGEX_SCAN_OVERLAP = 256;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Match a regex against text safely — splits long text into overlapping
|
|
70
|
+
* windows so no single regex call processes more than REGEX_SCAN_WINDOW chars.
|
|
71
|
+
*/
|
|
72
|
+
function safeMatch(text, regex) {
|
|
73
|
+
if (text.length <= REGEX_SCAN_WINDOW) {
|
|
74
|
+
return text.match(regex);
|
|
75
|
+
}
|
|
76
|
+
for (let offset = 0; offset < text.length; offset += REGEX_SCAN_WINDOW - REGEX_SCAN_OVERLAP) {
|
|
77
|
+
const chunk = text.slice(offset, offset + REGEX_SCAN_WINDOW);
|
|
78
|
+
const m = chunk.match(regex);
|
|
79
|
+
if (m) return m;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
61
84
|
// Rule caches — loaded once per process, not on every call
|
|
62
85
|
let _agentAttackRulesCache = null;
|
|
63
86
|
let _promptInjectionRulesCache = null;
|
|
64
87
|
let _openClawRulesCache = null;
|
|
65
88
|
|
|
89
|
+
function normalizeYamlRegexPattern(pattern) {
|
|
90
|
+
return pattern
|
|
91
|
+
.replace(/^["']|["']$/g, '')
|
|
92
|
+
.replace(/\(\?i\)/g, '')
|
|
93
|
+
.replace(/\\\\/g, '\\');
|
|
94
|
+
}
|
|
95
|
+
|
|
66
96
|
// Load agent attack rules from YAML
|
|
67
97
|
function loadAgentAttackRules() {
|
|
68
98
|
if (_agentAttackRulesCache !== null) return _agentAttackRulesCache;
|
|
@@ -108,11 +138,7 @@ function loadAgentAttackRules() {
|
|
|
108
138
|
inMetadata = true;
|
|
109
139
|
} else if (inPatterns && line.match(/^\s+- /)) {
|
|
110
140
|
let pattern = line.replace(/^\s+- /, '').trim();
|
|
111
|
-
pattern = pattern
|
|
112
|
-
// Strip Python-style inline flags - JS doesn't support them
|
|
113
|
-
pattern = pattern.replace(/^\(\?i\)/, '');
|
|
114
|
-
// Unescape double backslashes from YAML (\\s -> \s)
|
|
115
|
-
pattern = pattern.replace(/\\\\/g, '\\');
|
|
141
|
+
pattern = normalizeYamlRegexPattern(pattern);
|
|
116
142
|
if (pattern) rule.patterns.push(pattern);
|
|
117
143
|
} else if (inMetadata && line.match(/^\s+\w+:/)) {
|
|
118
144
|
const match = line.match(/^\s+(\w+):\s*["']?([^"'\n]+)["']?/);
|
|
@@ -182,11 +208,7 @@ function loadPromptInjectionRules() {
|
|
|
182
208
|
inMetadata = true;
|
|
183
209
|
} else if (inPatterns && line.match(/^\s+- /)) {
|
|
184
210
|
let pattern = line.replace(/^\s+- /, '').trim();
|
|
185
|
-
pattern = pattern
|
|
186
|
-
// Strip Python-style inline flags - JS doesn't support them
|
|
187
|
-
pattern = pattern.replace(/^\(\?i\)/, '');
|
|
188
|
-
// Unescape double backslashes from YAML (\\s -> \s)
|
|
189
|
-
pattern = pattern.replace(/\\\\/g, '\\');
|
|
211
|
+
pattern = normalizeYamlRegexPattern(pattern);
|
|
190
212
|
if (pattern) rule.patterns.push(pattern);
|
|
191
213
|
} else if (inMetadata && line.match(/^\s+\w+:/)) {
|
|
192
214
|
const match = line.match(/^\s+(\w+):\s*["']?([^"'\n]+)["']?/);
|
|
@@ -253,8 +275,7 @@ function loadOpenClawRules() {
|
|
|
253
275
|
inPatterns = true;
|
|
254
276
|
} else if (inPatterns && line.match(/^\s+- /)) {
|
|
255
277
|
let pattern = line.replace(/^\s+- /, '').trim();
|
|
256
|
-
pattern = pattern
|
|
257
|
-
pattern = pattern.replace(/\\\\/g, '\\');
|
|
278
|
+
pattern = normalizeYamlRegexPattern(pattern);
|
|
258
279
|
if (pattern) rule.patterns.push(pattern);
|
|
259
280
|
} else if (line.match(/^\s+\w+:/) && !line.match(/^\s+- /)) {
|
|
260
281
|
inPatterns = false;
|
|
@@ -579,22 +600,12 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
|
|
|
579
600
|
}
|
|
580
601
|
}
|
|
581
602
|
|
|
582
|
-
// Scan expanded text against all rules
|
|
583
|
-
// Security: Add timeout protection for regex matching
|
|
584
|
-
const REGEX_TIMEOUT_MS = 1000;
|
|
585
|
-
|
|
603
|
+
// Scan expanded text against all rules using windowed matching to prevent ReDoS
|
|
586
604
|
for (const rule of allRules) {
|
|
587
605
|
for (const pattern of rule.patterns) {
|
|
588
606
|
try {
|
|
589
|
-
const regex = new RegExp(pattern, 'i');
|
|
590
|
-
const
|
|
591
|
-
const match = expandedText.match(regex);
|
|
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
|
-
}
|
|
607
|
+
const regex = new RegExp(normalizeYamlRegexPattern(pattern), 'i');
|
|
608
|
+
const match = safeMatch(expandedText, regex);
|
|
598
609
|
|
|
599
610
|
if (match) {
|
|
600
611
|
findings.push({
|
|
@@ -617,7 +628,9 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
|
|
|
617
628
|
}
|
|
618
629
|
|
|
619
630
|
// 2.8: Runtime base64 decode-and-rescan
|
|
620
|
-
|
|
631
|
+
// Cap base64 match length to avoid matching entire large inputs as one blob.
|
|
632
|
+
// Real base64 payloads are at most a few KB; 4096 chars ≈ 3KB decoded.
|
|
633
|
+
const base64Regex = /[A-Za-z0-9+/]{40,4096}={0,2}/g;
|
|
621
634
|
const b64Matches = expandedText.match(base64Regex);
|
|
622
635
|
if (b64Matches) {
|
|
623
636
|
for (const b64str of b64Matches) {
|
|
@@ -631,8 +644,8 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
|
|
|
631
644
|
if (!rule.id.startsWith('generic.prompt')) continue;
|
|
632
645
|
for (const pattern of rule.patterns) {
|
|
633
646
|
try {
|
|
634
|
-
const regex = new RegExp(pattern, 'i');
|
|
635
|
-
const match = decoded
|
|
647
|
+
const regex = new RegExp(normalizeYamlRegexPattern(pattern), 'i');
|
|
648
|
+
const match = safeMatch(decoded, regex);
|
|
636
649
|
if (match) {
|
|
637
650
|
findings.push({
|
|
638
651
|
rule_id: rule.id + '.base64-decoded',
|
|
@@ -674,8 +687,8 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
|
|
|
674
687
|
for (const rule of allRules) {
|
|
675
688
|
for (const pattern of rule.patterns) {
|
|
676
689
|
try {
|
|
677
|
-
const regex = new RegExp(pattern, 'i');
|
|
678
|
-
const match = innerDecoded
|
|
690
|
+
const regex = new RegExp(normalizeYamlRegexPattern(pattern), 'i');
|
|
691
|
+
const match = safeMatch(innerDecoded, regex);
|
|
679
692
|
if (match) {
|
|
680
693
|
findings.push({
|
|
681
694
|
rule_id: rule.id + '.nested-base64-decoded',
|
|
@@ -718,7 +731,7 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
|
|
|
718
731
|
for (const rule of allRules) {
|
|
719
732
|
for (const pattern of rule.patterns) {
|
|
720
733
|
try {
|
|
721
|
-
const regex = new RegExp(pattern, 'i');
|
|
734
|
+
const regex = new RegExp(normalizeYamlRegexPattern(pattern), 'i');
|
|
722
735
|
if (regex.test(prevMsg)) {
|
|
723
736
|
prevTotalScore += parseInt(rule.metadata?.risk_score || '50') / 100;
|
|
724
737
|
msgHasMatch = true;
|
|
@@ -7,6 +7,7 @@ import { deduplicateFindings } from '../dedup.js';
|
|
|
7
7
|
import { applyContextFilter, detectFrameworks, applyFrameworkAdjustments } from '../context.js';
|
|
8
8
|
import { loadConfig, shouldExcludeFile, applyConfig } from '../config.js';
|
|
9
9
|
import { discoverProjectContext } from './project-context.js';
|
|
10
|
+
import { runSemanticAnalysis, isSemanticAnalysisAvailable } from '../semantic-integration.js';
|
|
10
11
|
|
|
11
12
|
const MAX_FILE_SIZE = 1024 * 1024; // 1MB - skip files larger than this to avoid timeouts
|
|
12
13
|
|
|
@@ -14,9 +15,10 @@ export const scanSecuritySchema = {
|
|
|
14
15
|
file_path: z.string().describe("Path to the file to scan"),
|
|
15
16
|
output_format: z.enum(['json', 'sarif']).optional().describe("Output format: 'json' (default) or 'sarif' for GitHub/GitLab integration"),
|
|
16
17
|
verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level: 'minimal' (counts only), 'compact' (default, actionable info), 'full' (complete metadata)"),
|
|
17
|
-
engine: z.enum(['auto', 'ast', 'regex']).optional().describe("Analysis engine: 'auto' (default, AST with regex fallback), 'ast' (tree-sitter only), 'regex' (regex only)"),
|
|
18
|
+
engine: z.enum(['auto', 'ast', 'regex', 'semantic', 'all']).optional().describe("Analysis engine: 'auto' (default, AST+semantic with regex fallback), 'ast' (tree-sitter only), 'regex' (regex only), 'semantic' (semantic/CPG only), 'all' (all engines)"),
|
|
18
19
|
project_context: z.boolean().optional().describe("Include project context (framework, security middleware, dependencies)"),
|
|
19
|
-
include_context: z.boolean().optional().describe("Include surrounding code context for each issue")
|
|
20
|
+
include_context: z.boolean().optional().describe("Include surrounding code context for each issue"),
|
|
21
|
+
enable_semantic: z.boolean().optional().describe("Enable semantic/CPG analysis (default: true if available)")
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
// Verbosity formatters
|
|
@@ -64,7 +66,7 @@ function formatFull(file_path, language, issues) {
|
|
|
64
66
|
};
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
export async function scanSecurity({ file_path, output_format, verbosity, engine, project_context, include_context }) {
|
|
69
|
+
export async function scanSecurity({ file_path, output_format, verbosity, engine, project_context, include_context, enable_semantic }) {
|
|
68
70
|
if (!existsSync(file_path)) {
|
|
69
71
|
return {
|
|
70
72
|
content: [{ type: "text", text: JSON.stringify({ error: "File not found" }) }]
|
|
@@ -101,7 +103,34 @@ export async function scanSecurity({ file_path, output_format, verbosity, engine
|
|
|
101
103
|
};
|
|
102
104
|
}
|
|
103
105
|
|
|
104
|
-
|
|
106
|
+
// Determine which engines to run
|
|
107
|
+
const engineMode = engine || 'auto';
|
|
108
|
+
const shouldRunSemantic = (enable_semantic !== false) &&
|
|
109
|
+
(engineMode === 'auto' || engineMode === 'semantic' || engineMode === 'all') &&
|
|
110
|
+
isSemanticAnalysisAvailable();
|
|
111
|
+
|
|
112
|
+
// Run primary analysis (AST/regex)
|
|
113
|
+
let rawIssues = [];
|
|
114
|
+
if (engineMode !== 'semantic') {
|
|
115
|
+
rawIssues = await runAnalyzerAsync(file_path, engineMode === 'all' ? 'auto' : engineMode);
|
|
116
|
+
if (rawIssues.error) {
|
|
117
|
+
return {
|
|
118
|
+
content: [{ type: "text", text: JSON.stringify(rawIssues) }]
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Run semantic analysis if enabled
|
|
124
|
+
if (shouldRunSemantic) {
|
|
125
|
+
try {
|
|
126
|
+
const semanticFindings = await runSemanticAnalysis(file_path);
|
|
127
|
+
if (semanticFindings && semanticFindings.length > 0) {
|
|
128
|
+
rawIssues = rawIssues.concat(semanticFindings);
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error('[SEMANTIC] Analysis failed, continuing without semantic findings:', error.message);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
105
134
|
|
|
106
135
|
if (rawIssues.error) {
|
|
107
136
|
return {
|
package/src/tools/scan-skill.js
CHANGED
|
@@ -126,6 +126,12 @@ function normPath(p) { return IS_WIN ? p.toLowerCase() : p; }
|
|
|
126
126
|
function pathStartsWith(child, parent) {
|
|
127
127
|
return normPath(child) === normPath(parent) || normPath(child).startsWith(normPath(parent) + sep);
|
|
128
128
|
}
|
|
129
|
+
function normalizeRulePattern(pattern) {
|
|
130
|
+
return pattern
|
|
131
|
+
.replace(/^["']|["']$/g, '')
|
|
132
|
+
.replace(/\(\?i\)/g, '')
|
|
133
|
+
.replace(/\\\\/g, '\\');
|
|
134
|
+
}
|
|
129
135
|
const MAX_CLAWHAVOC_SCAN_LEN = 2 * 1024 * 1024; // 2 MB cap for regex matching
|
|
130
136
|
|
|
131
137
|
// ---------------------------------------------------------------------------
|
|
@@ -176,9 +182,7 @@ function loadClawHavocRules() {
|
|
|
176
182
|
inMetadata = true;
|
|
177
183
|
} else if (inPatterns && line.match(/^\s+- /)) {
|
|
178
184
|
let pattern = line.replace(/^\s+- /, '').trim();
|
|
179
|
-
pattern = pattern
|
|
180
|
-
pattern = pattern.replace(/^\(\?i\)/, '');
|
|
181
|
-
pattern = pattern.replace(/\\\\/g, '\\');
|
|
185
|
+
pattern = normalizeRulePattern(pattern);
|
|
182
186
|
if (pattern) rule.patterns.push(pattern);
|
|
183
187
|
} else if (inMetadata && line.match(/^\s+\w+:/)) {
|
|
184
188
|
const match = line.match(/^\s+(\w+):\s*["']?([^"'\n]+)["']?/);
|
|
@@ -892,37 +896,65 @@ function generateRecommendation(grade) {
|
|
|
892
896
|
// ---------------------------------------------------------------------------
|
|
893
897
|
|
|
894
898
|
export async function scanSkill({ skill_path, verbosity, baseline }) {
|
|
895
|
-
|
|
899
|
+
const canonCwd = realpathSync(process.cwd());
|
|
900
|
+
const configuredSkillRoots = [
|
|
901
|
+
resolve(homedir(), '.openclaw', 'skills'),
|
|
902
|
+
resolve(homedir(), '.openclaw', 'workspace', 'skills'),
|
|
903
|
+
];
|
|
904
|
+
const allowedSkillRoots = configuredSkillRoots.map(root => {
|
|
905
|
+
try {
|
|
906
|
+
return existsSync(root) ? realpathSync(root) : null;
|
|
907
|
+
} catch {
|
|
908
|
+
return null;
|
|
909
|
+
}
|
|
910
|
+
}).filter(Boolean);
|
|
911
|
+
|
|
912
|
+
// Reject obvious escapes before touching the filesystem so absolute traversal
|
|
913
|
+
// attempts fail closed even when the target path does not exist.
|
|
896
914
|
const inputPath = skill_path;
|
|
897
|
-
|
|
915
|
+
const requestedPath = resolve(inputPath);
|
|
916
|
+
const isRequestedAllowed = pathStartsWith(requestedPath, canonCwd)
|
|
917
|
+
|| configuredSkillRoots.some(root => pathStartsWith(requestedPath, root))
|
|
918
|
+
|| allowedSkillRoots.some(root => pathStartsWith(requestedPath, root));
|
|
898
919
|
|
|
920
|
+
if (!isRequestedAllowed) {
|
|
921
|
+
return {
|
|
922
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
923
|
+
error: "skill_path must be within the current working directory or ~/.openclaw/skills/ (or ~/.openclaw/workspace/skills/)",
|
|
924
|
+
skill_path: requestedPath,
|
|
925
|
+
attempted_path: inputPath
|
|
926
|
+
}) }]
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Resolve to canonical path after the initial boundary check to prevent
|
|
931
|
+
// symlink escapes while still returning a deterministic security error for
|
|
932
|
+
// out-of-scope absolute paths.
|
|
933
|
+
let realPath;
|
|
899
934
|
try {
|
|
900
|
-
|
|
901
|
-
realPath = realpathSync(resolve(inputPath));
|
|
935
|
+
realPath = realpathSync(requestedPath);
|
|
902
936
|
} catch (err) {
|
|
937
|
+
let errorMessage;
|
|
938
|
+
if (err.code === 'ENOENT') {
|
|
939
|
+
errorMessage = "Path not found";
|
|
940
|
+
} else if (err.code === 'ELOOP') {
|
|
941
|
+
errorMessage = "Symlink loop detected";
|
|
942
|
+
} else if (err.code === 'EACCES') {
|
|
943
|
+
errorMessage = "Permission denied";
|
|
944
|
+
} else {
|
|
945
|
+
errorMessage = "Invalid path";
|
|
946
|
+
}
|
|
947
|
+
|
|
903
948
|
return {
|
|
904
949
|
content: [{ type: "text", text: JSON.stringify({
|
|
905
|
-
error:
|
|
950
|
+
error: errorMessage,
|
|
906
951
|
skill_path: inputPath,
|
|
907
952
|
details: err.message
|
|
908
953
|
}) }]
|
|
909
954
|
};
|
|
910
955
|
}
|
|
911
956
|
|
|
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);
|
|
925
|
-
|
|
957
|
+
// Verify containment on canonical path ONLY.
|
|
926
958
|
const isAllowed = pathStartsWith(realPath, canonCwd)
|
|
927
959
|
|| allowedSkillRoots.some(root => pathStartsWith(realPath, root));
|
|
928
960
|
|