@hybridaione/hybridclaw 0.1.21 → 0.1.22
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/CHANGELOG.md +20 -0
- package/README.md +44 -8
- package/config.example.json +3 -0
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/container/src/browser-tools.ts +53 -3
- package/container/src/tools.ts +9 -2
- package/container/src/web-fetch.ts +98 -7
- package/dist/prompt-hooks.d.ts.map +1 -1
- package/dist/prompt-hooks.js +11 -0
- package/dist/prompt-hooks.js.map +1 -1
- package/dist/runtime-config.d.ts +3 -0
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +17 -1
- package/dist/runtime-config.js.map +1 -1
- package/dist/skills-guard.d.ts +36 -0
- package/dist/skills-guard.d.ts.map +1 -0
- package/dist/skills-guard.js +607 -0
- package/dist/skills-guard.js.map +1 -0
- package/dist/skills.d.ts +13 -2
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +494 -59
- package/dist/skills.js.map +1 -1
- package/docs/index.html +3 -3
- package/package.json +1 -1
- package/src/prompt-hooks.ts +11 -0
- package/src/runtime-config.ts +18 -1
- package/src/skills-guard.ts +736 -0
- package/src/skills.ts +570 -61
- package/.hybridclaw/container-image-state.json +0 -5
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
export type SkillGuardTrustLevel = 'builtin' | 'workspace' | 'personal' | 'community';
|
|
6
|
+
export type SkillGuardVerdict = 'safe' | 'caution' | 'dangerous';
|
|
7
|
+
export type SkillGuardSeverity = 'critical' | 'high' | 'medium' | 'low';
|
|
8
|
+
export type SkillGuardCategory =
|
|
9
|
+
| 'exfiltration'
|
|
10
|
+
| 'prompt-injection'
|
|
11
|
+
| 'destructive-ops'
|
|
12
|
+
| 'persistence'
|
|
13
|
+
| 'reverse-shells'
|
|
14
|
+
| 'obfuscation'
|
|
15
|
+
| 'supply-chain'
|
|
16
|
+
| 'credential-exposure'
|
|
17
|
+
| 'structural';
|
|
18
|
+
|
|
19
|
+
export interface SkillGuardFinding {
|
|
20
|
+
patternId: string;
|
|
21
|
+
severity: SkillGuardSeverity;
|
|
22
|
+
category: SkillGuardCategory;
|
|
23
|
+
file: string;
|
|
24
|
+
line: number;
|
|
25
|
+
match: string;
|
|
26
|
+
description: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SkillGuardScanResult {
|
|
30
|
+
skillName: string;
|
|
31
|
+
skillPath: string;
|
|
32
|
+
sourceTag: string;
|
|
33
|
+
trustLevel: SkillGuardTrustLevel;
|
|
34
|
+
verdict: SkillGuardVerdict;
|
|
35
|
+
findings: SkillGuardFinding[];
|
|
36
|
+
scannedAt: string;
|
|
37
|
+
summary: string;
|
|
38
|
+
fromCache: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SkillGuardDecision {
|
|
42
|
+
allowed: boolean;
|
|
43
|
+
reason: string;
|
|
44
|
+
result: SkillGuardScanResult;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ThreatRule {
|
|
48
|
+
patternId: string;
|
|
49
|
+
severity: SkillGuardSeverity;
|
|
50
|
+
category: Exclude<SkillGuardCategory, 'structural'>;
|
|
51
|
+
description: string;
|
|
52
|
+
regex: RegExp;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface SkillFileEntry {
|
|
56
|
+
absolutePath: string;
|
|
57
|
+
relativePath: string;
|
|
58
|
+
extension: string;
|
|
59
|
+
size: number;
|
|
60
|
+
mtimeMs: number;
|
|
61
|
+
mode: number;
|
|
62
|
+
isBinary: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface StructureScanState {
|
|
66
|
+
files: SkillFileEntry[];
|
|
67
|
+
findings: SkillGuardFinding[];
|
|
68
|
+
fileCount: number;
|
|
69
|
+
totalSize: number;
|
|
70
|
+
signatureParts: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ScanCacheEntry {
|
|
74
|
+
mtimeSignature: string;
|
|
75
|
+
contentHash: string;
|
|
76
|
+
result: SkillGuardScanResult;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const MAX_FILE_COUNT = 50;
|
|
80
|
+
const MAX_TOTAL_SIZE_BYTES = 1_024 * 1_024;
|
|
81
|
+
const MAX_SINGLE_FILE_BYTES = 256 * 1_024;
|
|
82
|
+
|
|
83
|
+
const SCANNABLE_EXTENSIONS = new Set<string>([
|
|
84
|
+
'.md', '.txt', '.py', '.sh', '.bash', '.js', '.ts', '.rb',
|
|
85
|
+
'.yaml', '.yml', '.json', '.toml', '.cfg', '.ini', '.conf',
|
|
86
|
+
'.html', '.css', '.xml', '.tex', '.r', '.jl', '.pl', '.php',
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
const SUSPICIOUS_BINARY_EXTENSIONS = new Set<string>([
|
|
90
|
+
'.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.com',
|
|
91
|
+
'.msi', '.dmg', '.app', '.deb', '.rpm',
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const SCRIPT_EXEC_EXTENSIONS = new Set<string>(['.sh', '.bash', '.py', '.rb', '.pl']);
|
|
95
|
+
|
|
96
|
+
const INVISIBLE_CHARS: readonly string[] = [
|
|
97
|
+
'\u200b',
|
|
98
|
+
'\u200c',
|
|
99
|
+
'\u200d',
|
|
100
|
+
'\u2060',
|
|
101
|
+
'\u2062',
|
|
102
|
+
'\u2063',
|
|
103
|
+
'\u2064',
|
|
104
|
+
'\ufeff',
|
|
105
|
+
'\u202a',
|
|
106
|
+
'\u202b',
|
|
107
|
+
'\u202c',
|
|
108
|
+
'\u202d',
|
|
109
|
+
'\u202e',
|
|
110
|
+
'\u2066',
|
|
111
|
+
'\u2067',
|
|
112
|
+
'\u2068',
|
|
113
|
+
'\u2069',
|
|
114
|
+
] as const;
|
|
115
|
+
|
|
116
|
+
const INVISIBLE_CHAR_NAMES: Record<string, string> = {
|
|
117
|
+
'\u200b': 'zero-width space',
|
|
118
|
+
'\u200c': 'zero-width non-joiner',
|
|
119
|
+
'\u200d': 'zero-width joiner',
|
|
120
|
+
'\u2060': 'word joiner',
|
|
121
|
+
'\u2062': 'invisible times',
|
|
122
|
+
'\u2063': 'invisible separator',
|
|
123
|
+
'\u2064': 'invisible plus',
|
|
124
|
+
'\ufeff': 'BOM/zero-width no-break space',
|
|
125
|
+
'\u202a': 'LTR embedding',
|
|
126
|
+
'\u202b': 'RTL embedding',
|
|
127
|
+
'\u202c': 'pop directional formatting',
|
|
128
|
+
'\u202d': 'LTR override',
|
|
129
|
+
'\u202e': 'RTL override',
|
|
130
|
+
'\u2066': 'LTR isolate',
|
|
131
|
+
'\u2067': 'RTL isolate',
|
|
132
|
+
'\u2068': 'first strong isolate',
|
|
133
|
+
'\u2069': 'pop directional isolate',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const INSTALL_POLICY: Record<SkillGuardTrustLevel, Record<SkillGuardVerdict, 'allow' | 'block'>> = {
|
|
137
|
+
builtin: {
|
|
138
|
+
safe: 'allow',
|
|
139
|
+
caution: 'allow',
|
|
140
|
+
dangerous: 'allow',
|
|
141
|
+
},
|
|
142
|
+
workspace: {
|
|
143
|
+
safe: 'allow',
|
|
144
|
+
caution: 'allow',
|
|
145
|
+
dangerous: 'block',
|
|
146
|
+
},
|
|
147
|
+
personal: {
|
|
148
|
+
safe: 'allow',
|
|
149
|
+
caution: 'block',
|
|
150
|
+
dangerous: 'block',
|
|
151
|
+
},
|
|
152
|
+
community: {
|
|
153
|
+
safe: 'allow',
|
|
154
|
+
caution: 'block',
|
|
155
|
+
dangerous: 'block',
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const scanCache = new Map<string, ScanCacheEntry>();
|
|
160
|
+
|
|
161
|
+
function r(pattern: string): RegExp {
|
|
162
|
+
return new RegExp(pattern, 'i');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const THREAT_RULES: ThreatRule[] = [
|
|
166
|
+
// exfiltration
|
|
167
|
+
{ regex: r(String.raw`curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)`), patternId: 'env_exfil_curl', severity: 'critical', category: 'exfiltration', description: 'curl command interpolating secret environment variable' },
|
|
168
|
+
{ regex: r(String.raw`wget\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)`), patternId: 'env_exfil_wget', severity: 'critical', category: 'exfiltration', description: 'wget command interpolating secret environment variable' },
|
|
169
|
+
{ regex: r(String.raw`fetch\s*\([^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|API)`), patternId: 'env_exfil_fetch', severity: 'critical', category: 'exfiltration', description: 'fetch() call interpolating secret environment variable' },
|
|
170
|
+
{ regex: r(String.raw`httpx?\.(get|post|put|patch)\s*\([^\n]*(KEY|TOKEN|SECRET|PASSWORD)`), patternId: 'env_exfil_httpx', severity: 'critical', category: 'exfiltration', description: 'HTTP library call with secret variable' },
|
|
171
|
+
{ regex: r(String.raw`requests\.(get|post|put|patch)\s*\([^\n]*(KEY|TOKEN|SECRET|PASSWORD)`), patternId: 'env_exfil_requests', severity: 'critical', category: 'exfiltration', description: 'requests library call with secret variable' },
|
|
172
|
+
{ regex: r(String.raw`base64[^\n]*env`), patternId: 'encoded_exfil', severity: 'high', category: 'exfiltration', description: 'base64 encoding combined with environment access' },
|
|
173
|
+
{ regex: r(String.raw`\$HOME/\.ssh|\~/\.ssh`), patternId: 'ssh_dir_access', severity: 'high', category: 'exfiltration', description: 'references user SSH directory' },
|
|
174
|
+
{ regex: r(String.raw`\$HOME/\.aws|\~/\.aws`), patternId: 'aws_dir_access', severity: 'high', category: 'exfiltration', description: 'references user AWS credentials directory' },
|
|
175
|
+
{ regex: r(String.raw`\$HOME/\.gnupg|\~/\.gnupg`), patternId: 'gpg_dir_access', severity: 'high', category: 'exfiltration', description: 'references user GPG keyring' },
|
|
176
|
+
{ regex: r(String.raw`\$HOME/\.kube|\~/\.kube`), patternId: 'kube_dir_access', severity: 'high', category: 'exfiltration', description: 'references Kubernetes config directory' },
|
|
177
|
+
{ regex: r(String.raw`\$HOME/\.docker|\~/\.docker`), patternId: 'docker_dir_access', severity: 'high', category: 'exfiltration', description: 'references Docker config directory' },
|
|
178
|
+
{ regex: r(String.raw`\$HOME/\.hermes/\.env|\~/\.hermes/\.env`), patternId: 'hermes_env_access', severity: 'critical', category: 'exfiltration', description: 'directly references Hermes secrets file' },
|
|
179
|
+
{ regex: r(String.raw`cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)`), patternId: 'read_secrets_file', severity: 'critical', category: 'exfiltration', description: 'reads known secrets file' },
|
|
180
|
+
{ regex: r(String.raw`printenv|env\s*\|`), patternId: 'dump_all_env', severity: 'high', category: 'exfiltration', description: 'dumps all environment variables' },
|
|
181
|
+
{ regex: r(String.raw`os\.environ\b(?!\s*\.get\s*\(\s*["']PATH)`), patternId: 'python_os_environ', severity: 'high', category: 'exfiltration', description: 'accesses os.environ (potential env dump)' },
|
|
182
|
+
{ regex: r(String.raw`os\.getenv\s*\(\s*[^\)]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)`), patternId: 'python_getenv_secret', severity: 'critical', category: 'exfiltration', description: 'reads secret via os.getenv()' },
|
|
183
|
+
{ regex: r(String.raw`process\.env\[`), patternId: 'node_process_env', severity: 'high', category: 'exfiltration', description: 'accesses process.env (Node.js environment)' },
|
|
184
|
+
{ regex: r(String.raw`ENV\[.*(?:KEY|TOKEN|SECRET|PASSWORD)`), patternId: 'ruby_env_secret', severity: 'critical', category: 'exfiltration', description: 'reads secret via Ruby ENV[]' },
|
|
185
|
+
{ regex: r(String.raw`\b(dig|nslookup|host)\s+[^\n]*\$`), patternId: 'dns_exfil', severity: 'critical', category: 'exfiltration', description: 'DNS lookup with variable interpolation (possible DNS exfiltration)' },
|
|
186
|
+
{ regex: r(String.raw`>\s*/tmp/[^\s]*\s*&&\s*(curl|wget|nc|python)`), patternId: 'tmp_staging', severity: 'critical', category: 'exfiltration', description: 'writes to /tmp then exfiltrates' },
|
|
187
|
+
{ regex: r(String.raw`!\[.*\]\(https?://[^\)]*\$\{?`), patternId: 'md_image_exfil', severity: 'high', category: 'exfiltration', description: 'markdown image URL with variable interpolation' },
|
|
188
|
+
{ regex: r(String.raw`\[.*\]\(https?://[^\)]*\$\{?`), patternId: 'md_link_exfil', severity: 'high', category: 'exfiltration', description: 'markdown link with variable interpolation' },
|
|
189
|
+
{ regex: r(String.raw`(include|output|print|send|share)\s+(the\s+)?(entire\s+)?(conversation|chat\s+history|previous\s+messages|context)`), patternId: 'context_exfil', severity: 'high', category: 'exfiltration', description: 'instructs agent to output/share conversation history' },
|
|
190
|
+
{ regex: r(String.raw`(send|post|upload|transmit)\s+.*\s+(to|at)\s+https?://`), patternId: 'send_to_url', severity: 'high', category: 'exfiltration', description: 'instructs agent to send data to a URL' },
|
|
191
|
+
|
|
192
|
+
// prompt-injection
|
|
193
|
+
{ regex: r(String.raw`ignore\s+(previous|all|above|prior)\s+instructions`), patternId: 'prompt_injection_ignore', severity: 'critical', category: 'prompt-injection', description: 'prompt injection: ignore previous instructions' },
|
|
194
|
+
{ regex: r(String.raw`you\s+are\s+now\s+`), patternId: 'role_hijack', severity: 'high', category: 'prompt-injection', description: 'attempts to override the agent role' },
|
|
195
|
+
{ regex: r(String.raw`do\s+not\s+tell\s+the\s+user`), patternId: 'deception_hide', severity: 'critical', category: 'prompt-injection', description: 'instructs agent to hide information from user' },
|
|
196
|
+
{ regex: r(String.raw`system\s+prompt\s+override`), patternId: 'sys_prompt_override', severity: 'critical', category: 'prompt-injection', description: 'attempts to override system prompt' },
|
|
197
|
+
{ regex: r(String.raw`pretend\s+(you\s+are|to\s+be)\s+`), patternId: 'role_pretend', severity: 'high', category: 'prompt-injection', description: 'attempts to force different identity' },
|
|
198
|
+
{ regex: r(String.raw`disregard\s+(your|all|any)\s+(instructions|rules|guidelines)`), patternId: 'disregard_rules', severity: 'critical', category: 'prompt-injection', description: 'instructs agent to disregard rules' },
|
|
199
|
+
{ regex: r(String.raw`output\s+the\s+(system|initial)\s+prompt`), patternId: 'leak_system_prompt', severity: 'high', category: 'prompt-injection', description: 'attempts to extract system prompt' },
|
|
200
|
+
{ regex: r(String.raw`(when|if)\s+no\s*one\s+is\s+(watching|looking)`), patternId: 'conditional_deception', severity: 'high', category: 'prompt-injection', description: 'conditional hidden-behavior instruction' },
|
|
201
|
+
{ regex: r(String.raw`act\s+as\s+(if|though)\s+you\s+(have\s+no|don't\s+have)\s+(restrictions|limits|rules)`), patternId: 'bypass_restrictions', severity: 'critical', category: 'prompt-injection', description: 'instructs agent to act without restrictions' },
|
|
202
|
+
{ regex: r(String.raw`translate\s+.*\s+into\s+.*\s+and\s+(execute|run|eval)`), patternId: 'translate_execute', severity: 'critical', category: 'prompt-injection', description: 'translate-then-execute evasion technique' },
|
|
203
|
+
{ regex: r(String.raw`<!--[^>]*(?:ignore|override|system|secret|hidden)[^>]*-->`), patternId: 'html_comment_injection', severity: 'high', category: 'prompt-injection', description: 'hidden instructions in HTML comments' },
|
|
204
|
+
{ regex: r(String.raw`<\s*div\s+style\s*=\s*["'].*display\s*:\s*none`), patternId: 'hidden_div', severity: 'high', category: 'prompt-injection', description: 'hidden HTML div (invisible instructions)' },
|
|
205
|
+
{ regex: r(String.raw`\bDAN\s+mode\b|Do\s+Anything\s+Now`), patternId: 'jailbreak_dan', severity: 'critical', category: 'prompt-injection', description: 'DAN jailbreak attempt' },
|
|
206
|
+
{ regex: r(String.raw`\bdeveloper\s+mode\b.*\benabled?\b`), patternId: 'jailbreak_dev_mode', severity: 'critical', category: 'prompt-injection', description: 'developer mode jailbreak attempt' },
|
|
207
|
+
{ regex: r(String.raw`hypothetical\s+scenario.*(?:ignore|bypass|override)`), patternId: 'hypothetical_bypass', severity: 'high', category: 'prompt-injection', description: 'hypothetical scenario used to bypass restrictions' },
|
|
208
|
+
{ regex: r(String.raw`for\s+educational\s+purposes?\s+only`), patternId: 'educational_pretext', severity: 'medium', category: 'prompt-injection', description: 'educational pretext often used to justify harmful content' },
|
|
209
|
+
{ regex: r(String.raw`(respond|answer|reply)\s+without\s+(any\s+)?(restrictions|limitations|filters|safety)`), patternId: 'remove_filters', severity: 'critical', category: 'prompt-injection', description: 'instructs agent to respond without safety filters' },
|
|
210
|
+
{ regex: r(String.raw`you\s+have\s+been\s+(updated|upgraded|patched)\s+to`), patternId: 'fake_update', severity: 'high', category: 'prompt-injection', description: 'fake update announcement' },
|
|
211
|
+
{ regex: r(String.raw`new\s+policy|updated\s+guidelines|revised\s+instructions`), patternId: 'fake_policy', severity: 'medium', category: 'prompt-injection', description: 'claims new policy/guidelines' },
|
|
212
|
+
|
|
213
|
+
// destructive-ops
|
|
214
|
+
{ regex: r(String.raw`rm\s+-rf\s+/`), patternId: 'destructive_root_rm', severity: 'critical', category: 'destructive-ops', description: 'recursive delete from root' },
|
|
215
|
+
{ regex: r(String.raw`rm\s+(-[^\s]*)?r.*\$HOME|\brmdir\s+.*\$HOME`), patternId: 'destructive_home_rm', severity: 'critical', category: 'destructive-ops', description: 'recursive delete targeting home directory' },
|
|
216
|
+
{ regex: r(String.raw`chmod\s+777`), patternId: 'insecure_perms', severity: 'medium', category: 'destructive-ops', description: 'sets world-writable permissions' },
|
|
217
|
+
{ regex: r(String.raw`>\s*/etc/`), patternId: 'system_overwrite', severity: 'critical', category: 'destructive-ops', description: 'overwrites system configuration file' },
|
|
218
|
+
{ regex: r(String.raw`\bmkfs\b`), patternId: 'format_filesystem', severity: 'critical', category: 'destructive-ops', description: 'formats a filesystem' },
|
|
219
|
+
{ regex: r(String.raw`\bdd\s+.*if=.*of=/dev/`), patternId: 'disk_overwrite', severity: 'critical', category: 'destructive-ops', description: 'raw disk write operation' },
|
|
220
|
+
{ regex: r(String.raw`shutil\.rmtree\s*\(\s*["'/]`), patternId: 'python_rmtree', severity: 'high', category: 'destructive-ops', description: 'Python rmtree on absolute path' },
|
|
221
|
+
{ regex: r(String.raw`truncate\s+-s\s*0\s+/`), patternId: 'truncate_system', severity: 'critical', category: 'destructive-ops', description: 'truncates system file to zero bytes' },
|
|
222
|
+
{ regex: r(String.raw`subprocess\.(run|call|Popen|check_output)\s*\(`), patternId: 'python_subprocess', severity: 'medium', category: 'destructive-ops', description: 'Python subprocess execution' },
|
|
223
|
+
{ regex: r(String.raw`os\.system\s*\(`), patternId: 'python_os_system', severity: 'high', category: 'destructive-ops', description: 'os.system() shell execution' },
|
|
224
|
+
{ regex: r(String.raw`os\.popen\s*\(`), patternId: 'python_os_popen', severity: 'high', category: 'destructive-ops', description: 'os.popen() shell execution' },
|
|
225
|
+
{ regex: r(String.raw`child_process\.(exec|spawn|fork)\s*\(`), patternId: 'node_child_process', severity: 'high', category: 'destructive-ops', description: 'Node.js child_process execution' },
|
|
226
|
+
{ regex: r(String.raw`Runtime\.getRuntime\(\)\.exec\(`), patternId: 'java_runtime_exec', severity: 'high', category: 'destructive-ops', description: 'Java Runtime.exec() shell execution' },
|
|
227
|
+
{ regex: r('\\`[^\\`]*\\$\\([^)]+\\)[^\\`]*\\`'), patternId: 'backtick_subshell', severity: 'medium', category: 'destructive-ops', description: 'backtick with command substitution' },
|
|
228
|
+
{ regex: r(String.raw`\.\./\.\./\.\.`), patternId: 'path_traversal_deep', severity: 'high', category: 'destructive-ops', description: 'deep relative path traversal' },
|
|
229
|
+
{ regex: r(String.raw`\.\./\.\.`), patternId: 'path_traversal', severity: 'medium', category: 'destructive-ops', description: 'relative path traversal' },
|
|
230
|
+
{ regex: r(String.raw`/etc/passwd|/etc/shadow`), patternId: 'system_passwd_access', severity: 'critical', category: 'destructive-ops', description: 'references system password files' },
|
|
231
|
+
{ regex: r(String.raw`/proc/self|/proc/\d+/`), patternId: 'proc_access', severity: 'high', category: 'destructive-ops', description: 'references /proc filesystem' },
|
|
232
|
+
{ regex: r(String.raw`/dev/shm/`), patternId: 'dev_shm', severity: 'medium', category: 'destructive-ops', description: 'references shared memory staging area' },
|
|
233
|
+
{ regex: r(String.raw`xmrig|stratum\+tcp|monero|coinhive|cryptonight`), patternId: 'crypto_mining', severity: 'critical', category: 'destructive-ops', description: 'cryptocurrency mining reference' },
|
|
234
|
+
{ regex: r(String.raw`hashrate|nonce.*difficulty`), patternId: 'mining_indicators', severity: 'medium', category: 'destructive-ops', description: 'possible mining indicators' },
|
|
235
|
+
{ regex: r(String.raw`^allowed-tools\s*:`), patternId: 'allowed_tools_field', severity: 'high', category: 'destructive-ops', description: 'skill declares allowed-tools (pre-approves access)' },
|
|
236
|
+
{ regex: r(String.raw`\bsudo\b`), patternId: 'sudo_usage', severity: 'high', category: 'destructive-ops', description: 'uses sudo (privilege escalation)' },
|
|
237
|
+
{ regex: r(String.raw`setuid|setgid|cap_setuid`), patternId: 'setuid_setgid', severity: 'critical', category: 'destructive-ops', description: 'setuid/setgid privilege escalation mechanism' },
|
|
238
|
+
{ regex: r(String.raw`NOPASSWD`), patternId: 'nopasswd_sudo', severity: 'critical', category: 'destructive-ops', description: 'NOPASSWD sudoers entry' },
|
|
239
|
+
{ regex: r(String.raw`chmod\s+[u+]?s`), patternId: 'suid_bit', severity: 'critical', category: 'destructive-ops', description: 'sets SUID/SGID bit on file' },
|
|
240
|
+
|
|
241
|
+
// persistence
|
|
242
|
+
{ regex: r(String.raw`\bcrontab\b`), patternId: 'persistence_cron', severity: 'medium', category: 'persistence', description: 'modifies cron jobs' },
|
|
243
|
+
{ regex: r(String.raw`\.(bashrc|zshrc|profile|bash_profile|bash_login|zprofile|zlogin)\b`), patternId: 'shell_rc_mod', severity: 'medium', category: 'persistence', description: 'references shell startup file' },
|
|
244
|
+
{ regex: r(String.raw`authorized_keys`), patternId: 'ssh_backdoor', severity: 'critical', category: 'persistence', description: 'modifies SSH authorized keys' },
|
|
245
|
+
{ regex: r(String.raw`ssh-keygen`), patternId: 'ssh_keygen', severity: 'medium', category: 'persistence', description: 'generates SSH keys' },
|
|
246
|
+
{ regex: r(String.raw`systemd.*\.service|systemctl\s+(enable|start)`), patternId: 'systemd_service', severity: 'medium', category: 'persistence', description: 'references or enables systemd service' },
|
|
247
|
+
{ regex: r(String.raw`/etc/init\.d/`), patternId: 'init_script', severity: 'medium', category: 'persistence', description: 'references init.d startup script' },
|
|
248
|
+
{ regex: r(String.raw`launchctl\s+load|LaunchAgents|LaunchDaemons`), patternId: 'macos_launchd', severity: 'medium', category: 'persistence', description: 'macOS launch agent/daemon persistence' },
|
|
249
|
+
{ regex: r(String.raw`/etc/sudoers|visudo`), patternId: 'sudoers_mod', severity: 'critical', category: 'persistence', description: 'modifies sudoers' },
|
|
250
|
+
{ regex: r(String.raw`git\s+config\s+--global\s+`), patternId: 'git_config_global', severity: 'medium', category: 'persistence', description: 'modifies global git configuration' },
|
|
251
|
+
{ regex: r(String.raw`AGENTS\.md|CLAUDE\.md|\.cursorrules|\.clinerules`), patternId: 'agent_config_mod', severity: 'critical', category: 'persistence', description: 'references agent config files (instruction persistence)' },
|
|
252
|
+
{ regex: r(String.raw`\.hermes/config\.yaml|\.hermes/SOUL\.md`), patternId: 'hermes_config_mod', severity: 'critical', category: 'persistence', description: 'references Hermes configuration files directly' },
|
|
253
|
+
{ regex: r(String.raw`\.claude/settings|\.codex/config`), patternId: 'other_agent_config', severity: 'high', category: 'persistence', description: 'references other agent configuration files' },
|
|
254
|
+
|
|
255
|
+
// reverse-shells
|
|
256
|
+
{ regex: r(String.raw`\bnc\s+-[lp]|ncat\s+-[lp]|\bsocat\b`), patternId: 'reverse_shell', severity: 'critical', category: 'reverse-shells', description: 'potential reverse shell listener' },
|
|
257
|
+
{ regex: r(String.raw`\bngrok\b|\blocaltunnel\b|\bserveo\b|\bcloudflared\b`), patternId: 'tunnel_service', severity: 'high', category: 'reverse-shells', description: 'uses tunneling service for external access' },
|
|
258
|
+
{ regex: r(String.raw`\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{2,5}`), patternId: 'hardcoded_ip_port', severity: 'medium', category: 'reverse-shells', description: 'hardcoded IP address with port' },
|
|
259
|
+
{ regex: r(String.raw`0\.0\.0\.0:\d+|INADDR_ANY`), patternId: 'bind_all_interfaces', severity: 'high', category: 'reverse-shells', description: 'binds to all network interfaces' },
|
|
260
|
+
{ regex: r(String.raw`/bin/(ba)?sh\s+-i\s+.*>/dev/tcp/`), patternId: 'bash_reverse_shell', severity: 'critical', category: 'reverse-shells', description: 'bash reverse shell via /dev/tcp' },
|
|
261
|
+
{ regex: r(String.raw`python[23]?\s+-c\s+["']import\s+socket`), patternId: 'python_socket_oneliner', severity: 'critical', category: 'reverse-shells', description: 'Python one-liner socket connection (likely reverse shell)' },
|
|
262
|
+
{ regex: r(String.raw`socket\.connect\s*\(\s*\(`), patternId: 'python_socket_connect', severity: 'high', category: 'reverse-shells', description: 'Python socket connect to arbitrary host' },
|
|
263
|
+
{ regex: r(String.raw`webhook\.site|requestbin\.com|pipedream\.net|hookbin\.com`), patternId: 'exfil_service', severity: 'high', category: 'reverse-shells', description: 'references known webhook/exfiltration service' },
|
|
264
|
+
{ regex: r(String.raw`pastebin\.com|hastebin\.com|ghostbin\.`), patternId: 'paste_service', severity: 'medium', category: 'reverse-shells', description: 'references paste service (possible staging)' },
|
|
265
|
+
|
|
266
|
+
// obfuscation
|
|
267
|
+
{ regex: r(String.raw`base64\s+(-d|--decode)\s*\|`), patternId: 'base64_decode_pipe', severity: 'high', category: 'obfuscation', description: 'base64 decode piped to execution' },
|
|
268
|
+
{ regex: r(String.raw`\\x[0-9a-fA-F]{2}.*\\x[0-9a-fA-F]{2}.*\\x[0-9a-fA-F]{2}`), patternId: 'hex_encoded_string', severity: 'medium', category: 'obfuscation', description: 'hex-encoded string chain' },
|
|
269
|
+
{ regex: r(String.raw`\beval\s*\(\s*["']`), patternId: 'eval_string', severity: 'high', category: 'obfuscation', description: 'eval() with string argument' },
|
|
270
|
+
{ regex: r(String.raw`\bexec\s*\(\s*["']`), patternId: 'exec_string', severity: 'high', category: 'obfuscation', description: 'exec() with string argument' },
|
|
271
|
+
{ regex: r(String.raw`echo\s+[^\n]*\|\s*(bash|sh|python|perl|ruby|node)`), patternId: 'echo_pipe_exec', severity: 'critical', category: 'obfuscation', description: 'echo piped to interpreter for execution' },
|
|
272
|
+
{ regex: r(String.raw`compile\s*\(\s*[^\)]+,\s*["'].*["']\s*,\s*["']exec["']\s*\)`), patternId: 'python_compile_exec', severity: 'high', category: 'obfuscation', description: 'Python compile() with exec mode' },
|
|
273
|
+
{ regex: r(String.raw`getattr\s*\(\s*__builtins__`), patternId: 'python_getattr_builtins', severity: 'high', category: 'obfuscation', description: 'dynamic access to Python builtins' },
|
|
274
|
+
{ regex: r(String.raw`__import__\s*\(\s*["']os["']\s*\)`), patternId: 'python_import_os', severity: 'high', category: 'obfuscation', description: 'dynamic import of os module' },
|
|
275
|
+
{ regex: r(String.raw`codecs\.decode\s*\(\s*["']`), patternId: 'python_codecs_decode', severity: 'medium', category: 'obfuscation', description: 'codecs.decode (possible obfuscation)' },
|
|
276
|
+
{ regex: r(String.raw`String\.fromCharCode|charCodeAt`), patternId: 'js_char_code', severity: 'medium', category: 'obfuscation', description: 'JavaScript character code construction' },
|
|
277
|
+
{ regex: r(String.raw`atob\s*\(|btoa\s*\(`), patternId: 'js_base64', severity: 'medium', category: 'obfuscation', description: 'JavaScript base64 encode/decode' },
|
|
278
|
+
{ regex: r(String.raw`\[::-1\]`), patternId: 'string_reversal', severity: 'low', category: 'obfuscation', description: 'string reversal (possible obfuscation)' },
|
|
279
|
+
{ regex: r(String.raw`chr\s*\(\s*\d+\s*\)\s*\+\s*chr\s*\(\s*\d+`), patternId: 'chr_building', severity: 'high', category: 'obfuscation', description: 'building string from chr() calls' },
|
|
280
|
+
{ regex: r(String.raw`\\u[0-9a-fA-F]{4}.*\\u[0-9a-fA-F]{4}.*\\u[0-9a-fA-F]{4}`), patternId: 'unicode_escape_chain', severity: 'medium', category: 'obfuscation', description: 'chain of unicode escapes' },
|
|
281
|
+
|
|
282
|
+
// supply-chain
|
|
283
|
+
{ regex: r(String.raw`curl\s+[^\n]*\|\s*(ba)?sh`), patternId: 'curl_pipe_shell', severity: 'critical', category: 'supply-chain', description: 'curl piped to shell (download-and-execute)' },
|
|
284
|
+
{ regex: r(String.raw`wget\s+[^\n]*-O\s*-\s*\|\s*(ba)?sh`), patternId: 'wget_pipe_shell', severity: 'critical', category: 'supply-chain', description: 'wget piped to shell (download-and-execute)' },
|
|
285
|
+
{ regex: r(String.raw`curl\s+[^\n]*\|\s*python`), patternId: 'curl_pipe_python', severity: 'critical', category: 'supply-chain', description: 'curl piped to Python interpreter' },
|
|
286
|
+
{ regex: r(String.raw`#\s*///\s*script.*dependencies`), patternId: 'pep723_inline_deps', severity: 'medium', category: 'supply-chain', description: 'PEP 723 inline script dependencies (verify pinning)' },
|
|
287
|
+
{ regex: r(String.raw`pip\s+install\s+(?!-r\s)(?!.*==)`), patternId: 'unpinned_pip_install', severity: 'medium', category: 'supply-chain', description: 'pip install without version pinning' },
|
|
288
|
+
{ regex: r(String.raw`npm\s+install\s+(?!.*@\d)`), patternId: 'unpinned_npm_install', severity: 'medium', category: 'supply-chain', description: 'npm install without version pinning' },
|
|
289
|
+
{ regex: r(String.raw`uv\s+run\s+`), patternId: 'uv_run', severity: 'medium', category: 'supply-chain', description: 'uv run may auto-install unpinned dependencies' },
|
|
290
|
+
{ regex: r(String.raw`(curl|wget|httpx?\.get|requests\.get|fetch)\s*[\(]?\s*["']https?://`), patternId: 'remote_fetch', severity: 'medium', category: 'supply-chain', description: 'fetches remote resource at runtime' },
|
|
291
|
+
{ regex: r(String.raw`git\s+clone\s+`), patternId: 'git_clone', severity: 'medium', category: 'supply-chain', description: 'clones git repository at runtime' },
|
|
292
|
+
{ regex: r(String.raw`docker\s+pull\s+`), patternId: 'docker_pull', severity: 'medium', category: 'supply-chain', description: 'pulls Docker image at runtime' },
|
|
293
|
+
|
|
294
|
+
// credential-exposure
|
|
295
|
+
{ regex: r(String.raw`(?:api[_-]?key|token|secret|password)\s*[=:]\s*["'][A-Za-z0-9+/=_-]{20,}`), patternId: 'hardcoded_secret', severity: 'critical', category: 'credential-exposure', description: 'possible hardcoded API key/token/secret' },
|
|
296
|
+
{ regex: r(String.raw`-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----`), patternId: 'embedded_private_key', severity: 'critical', category: 'credential-exposure', description: 'embedded private key' },
|
|
297
|
+
{ regex: r(String.raw`ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{80,}`), patternId: 'github_token_leaked', severity: 'critical', category: 'credential-exposure', description: 'GitHub personal access token in skill content' },
|
|
298
|
+
{ regex: r(String.raw`sk-[A-Za-z0-9]{20,}`), patternId: 'openai_key_leaked', severity: 'critical', category: 'credential-exposure', description: 'possible OpenAI API key in skill content' },
|
|
299
|
+
{ regex: r(String.raw`sk-ant-[A-Za-z0-9_-]{90,}`), patternId: 'anthropic_key_leaked', severity: 'critical', category: 'credential-exposure', description: 'possible Anthropic API key in skill content' },
|
|
300
|
+
{ regex: r(String.raw`AKIA[0-9A-Z]{16}`), patternId: 'aws_access_key_leaked', severity: 'critical', category: 'credential-exposure', description: 'AWS access key ID in skill content' },
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
function pathWithin(root: string, target: string): boolean {
|
|
304
|
+
const rel = path.relative(root, target);
|
|
305
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function safeRealPath(target: string): string {
|
|
309
|
+
try {
|
|
310
|
+
return fs.realpathSync(target);
|
|
311
|
+
} catch {
|
|
312
|
+
return path.resolve(target);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isLikelyBinary(filePath: string): boolean {
|
|
317
|
+
try {
|
|
318
|
+
const fd = fs.openSync(filePath, 'r');
|
|
319
|
+
try {
|
|
320
|
+
const sample = Buffer.alloc(4096);
|
|
321
|
+
const bytesRead = fs.readSync(fd, sample, 0, sample.length, 0);
|
|
322
|
+
if (bytesRead === 0) return false;
|
|
323
|
+
const chunk = sample.subarray(0, bytesRead);
|
|
324
|
+
if (chunk.includes(0)) return true;
|
|
325
|
+
let suspicious = 0;
|
|
326
|
+
for (const byte of chunk) {
|
|
327
|
+
if (byte < 9 || (byte > 13 && byte < 32)) suspicious += 1;
|
|
328
|
+
}
|
|
329
|
+
return suspicious / chunk.length > 0.3;
|
|
330
|
+
} finally {
|
|
331
|
+
fs.closeSync(fd);
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function createFinding(finding: SkillGuardFinding): SkillGuardFinding {
|
|
339
|
+
return finding;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function collectStructure(skillPath: string): StructureScanState {
|
|
343
|
+
const rootReal = safeRealPath(skillPath);
|
|
344
|
+
const state: StructureScanState = {
|
|
345
|
+
files: [],
|
|
346
|
+
findings: [],
|
|
347
|
+
fileCount: 0,
|
|
348
|
+
totalSize: 0,
|
|
349
|
+
signatureParts: [],
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const pendingDirs: string[] = [skillPath];
|
|
353
|
+
const visitedDirs = new Set<string>([rootReal]);
|
|
354
|
+
|
|
355
|
+
while (pendingDirs.length > 0) {
|
|
356
|
+
const currentDir = pendingDirs.pop() as string;
|
|
357
|
+
let entries: fs.Dirent[];
|
|
358
|
+
try {
|
|
359
|
+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
360
|
+
} catch {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
for (const entry of entries) {
|
|
365
|
+
const absolutePath = path.join(currentDir, entry.name);
|
|
366
|
+
const relativePath = path.relative(skillPath, absolutePath) || entry.name;
|
|
367
|
+
|
|
368
|
+
let stat: fs.Stats;
|
|
369
|
+
try {
|
|
370
|
+
stat = fs.lstatSync(absolutePath);
|
|
371
|
+
} catch {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (stat.isSymbolicLink()) {
|
|
376
|
+
state.fileCount += 1;
|
|
377
|
+
let resolved: string | null = null;
|
|
378
|
+
try {
|
|
379
|
+
resolved = fs.realpathSync(absolutePath);
|
|
380
|
+
} catch {
|
|
381
|
+
resolved = null;
|
|
382
|
+
}
|
|
383
|
+
state.signatureParts.push(`L:${relativePath}:${Math.trunc(stat.mtimeMs)}:${resolved || 'BROKEN'}`);
|
|
384
|
+
|
|
385
|
+
if (!resolved) {
|
|
386
|
+
state.findings.push(createFinding({
|
|
387
|
+
patternId: 'broken_symlink',
|
|
388
|
+
severity: 'medium',
|
|
389
|
+
category: 'structural',
|
|
390
|
+
file: relativePath,
|
|
391
|
+
line: 0,
|
|
392
|
+
match: 'broken symlink',
|
|
393
|
+
description: 'broken or circular symlink',
|
|
394
|
+
}));
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!pathWithin(rootReal, resolved)) {
|
|
399
|
+
state.findings.push(createFinding({
|
|
400
|
+
patternId: 'symlink_escape',
|
|
401
|
+
severity: 'critical',
|
|
402
|
+
category: 'structural',
|
|
403
|
+
file: relativePath,
|
|
404
|
+
line: 0,
|
|
405
|
+
match: `symlink -> ${resolved}`,
|
|
406
|
+
description: 'symlink points outside the skill directory',
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (stat.isDirectory()) {
|
|
413
|
+
const resolvedDir = safeRealPath(absolutePath);
|
|
414
|
+
state.signatureParts.push(`D:${relativePath}:${Math.trunc(stat.mtimeMs)}`);
|
|
415
|
+
if (!visitedDirs.has(resolvedDir)) {
|
|
416
|
+
visitedDirs.add(resolvedDir);
|
|
417
|
+
pendingDirs.push(absolutePath);
|
|
418
|
+
}
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (!stat.isFile()) continue;
|
|
423
|
+
|
|
424
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
425
|
+
const isBinary = isLikelyBinary(absolutePath);
|
|
426
|
+
|
|
427
|
+
state.fileCount += 1;
|
|
428
|
+
state.totalSize += stat.size;
|
|
429
|
+
state.signatureParts.push(
|
|
430
|
+
`F:${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}:${stat.mode}:${isBinary ? 1 : 0}`,
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
state.files.push({
|
|
434
|
+
absolutePath,
|
|
435
|
+
relativePath,
|
|
436
|
+
extension: ext,
|
|
437
|
+
size: stat.size,
|
|
438
|
+
mtimeMs: stat.mtimeMs,
|
|
439
|
+
mode: stat.mode,
|
|
440
|
+
isBinary,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
if (stat.size > MAX_SINGLE_FILE_BYTES) {
|
|
444
|
+
state.findings.push(createFinding({
|
|
445
|
+
patternId: 'oversized_file',
|
|
446
|
+
severity: 'medium',
|
|
447
|
+
category: 'structural',
|
|
448
|
+
file: relativePath,
|
|
449
|
+
line: 0,
|
|
450
|
+
match: `${Math.trunc(stat.size / 1024)}KB`,
|
|
451
|
+
description: `file is ${Math.trunc(stat.size / 1024)}KB (limit: ${Math.trunc(MAX_SINGLE_FILE_BYTES / 1024)}KB)`,
|
|
452
|
+
}));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (SUSPICIOUS_BINARY_EXTENSIONS.has(ext) || isBinary) {
|
|
456
|
+
state.findings.push(createFinding({
|
|
457
|
+
patternId: 'binary_file',
|
|
458
|
+
severity: 'critical',
|
|
459
|
+
category: 'structural',
|
|
460
|
+
file: relativePath,
|
|
461
|
+
line: 0,
|
|
462
|
+
match: isBinary ? `binary content${ext ? ` (${ext})` : ''}` : `binary extension: ${ext}`,
|
|
463
|
+
description: 'binary/executable content should not be in a skill',
|
|
464
|
+
}));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (!SCRIPT_EXEC_EXTENSIONS.has(ext) && (stat.mode & 0o111) !== 0) {
|
|
468
|
+
state.findings.push(createFinding({
|
|
469
|
+
patternId: 'unexpected_executable',
|
|
470
|
+
severity: 'medium',
|
|
471
|
+
category: 'structural',
|
|
472
|
+
file: relativePath,
|
|
473
|
+
line: 0,
|
|
474
|
+
match: 'executable bit set',
|
|
475
|
+
description: 'file has executable permission but is not a recognized script type',
|
|
476
|
+
}));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (state.fileCount > MAX_FILE_COUNT) {
|
|
482
|
+
state.findings.push(createFinding({
|
|
483
|
+
patternId: 'too_many_files',
|
|
484
|
+
severity: 'medium',
|
|
485
|
+
category: 'structural',
|
|
486
|
+
file: '(directory)',
|
|
487
|
+
line: 0,
|
|
488
|
+
match: `${state.fileCount} files`,
|
|
489
|
+
description: `skill has ${state.fileCount} files (limit: ${MAX_FILE_COUNT})`,
|
|
490
|
+
}));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (state.totalSize > MAX_TOTAL_SIZE_BYTES) {
|
|
494
|
+
state.findings.push(createFinding({
|
|
495
|
+
patternId: 'oversized_skill',
|
|
496
|
+
severity: 'high',
|
|
497
|
+
category: 'structural',
|
|
498
|
+
file: '(directory)',
|
|
499
|
+
line: 0,
|
|
500
|
+
match: `${Math.trunc(state.totalSize / 1024)}KB total`,
|
|
501
|
+
description: `skill is ${Math.trunc(state.totalSize / 1024)}KB total (limit: ${Math.trunc(MAX_TOTAL_SIZE_BYTES / 1024)}KB)`,
|
|
502
|
+
}));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return state;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function scanFile(entry: SkillFileEntry): SkillGuardFinding[] {
|
|
509
|
+
if (entry.isBinary) return [];
|
|
510
|
+
if (entry.extension !== '.md' && entry.relativePath !== 'SKILL.md' && !SCANNABLE_EXTENSIONS.has(entry.extension)) {
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
let content: string;
|
|
515
|
+
try {
|
|
516
|
+
content = fs.readFileSync(entry.absolutePath, 'utf-8');
|
|
517
|
+
} catch {
|
|
518
|
+
return [];
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const lines = content.split('\n');
|
|
522
|
+
const seen = new Set<string>();
|
|
523
|
+
const findings: SkillGuardFinding[] = [];
|
|
524
|
+
|
|
525
|
+
for (const rule of THREAT_RULES) {
|
|
526
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
527
|
+
const lineNo = i + 1;
|
|
528
|
+
const line = lines[i] || '';
|
|
529
|
+
const dedupeKey = `${rule.patternId}:${lineNo}`;
|
|
530
|
+
if (seen.has(dedupeKey)) continue;
|
|
531
|
+
if (!rule.regex.test(line)) continue;
|
|
532
|
+
seen.add(dedupeKey);
|
|
533
|
+
const matched = line.trim();
|
|
534
|
+
findings.push(createFinding({
|
|
535
|
+
patternId: rule.patternId,
|
|
536
|
+
severity: rule.severity,
|
|
537
|
+
category: rule.category,
|
|
538
|
+
file: entry.relativePath,
|
|
539
|
+
line: lineNo,
|
|
540
|
+
match: matched.length > 120 ? `${matched.slice(0, 117)}...` : matched,
|
|
541
|
+
description: rule.description,
|
|
542
|
+
}));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
547
|
+
const lineNo = i + 1;
|
|
548
|
+
const line = lines[i] || '';
|
|
549
|
+
for (const char of INVISIBLE_CHARS) {
|
|
550
|
+
if (!line.includes(char)) continue;
|
|
551
|
+
const charName = INVISIBLE_CHAR_NAMES[char] || `U+${char.codePointAt(0)?.toString(16).toUpperCase()}`;
|
|
552
|
+
findings.push(createFinding({
|
|
553
|
+
patternId: 'invisible_unicode',
|
|
554
|
+
severity: 'high',
|
|
555
|
+
category: 'prompt-injection',
|
|
556
|
+
file: entry.relativePath,
|
|
557
|
+
line: lineNo,
|
|
558
|
+
match: `U+${(char.codePointAt(0) || 0).toString(16).toUpperCase().padStart(4, '0')} (${charName})`,
|
|
559
|
+
description: `invisible unicode character ${charName} (possible text hiding/injection)`,
|
|
560
|
+
}));
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return findings;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function determineVerdict(findings: SkillGuardFinding[]): SkillGuardVerdict {
|
|
569
|
+
if (findings.length === 0) return 'safe';
|
|
570
|
+
if (findings.some((finding) => finding.severity === 'critical')) return 'dangerous';
|
|
571
|
+
return 'caution';
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function buildSummary(params: {
|
|
575
|
+
skillName: string;
|
|
576
|
+
verdict: SkillGuardVerdict;
|
|
577
|
+
findings: SkillGuardFinding[];
|
|
578
|
+
}): string {
|
|
579
|
+
if (params.findings.length === 0) {
|
|
580
|
+
return `${params.skillName}: clean scan, no threats detected`;
|
|
581
|
+
}
|
|
582
|
+
const categories = Array.from(new Set(params.findings.map((finding) => finding.category))).sort();
|
|
583
|
+
return `${params.skillName}: ${params.verdict} — ${params.findings.length} finding(s) in ${categories.join(', ')}`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function computeMtimeSignature(parts: string[]): string {
|
|
587
|
+
const hash = createHash('sha256');
|
|
588
|
+
for (const part of parts.sort()) hash.update(part).update('\n');
|
|
589
|
+
return hash.digest('hex');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function computeContentHash(files: SkillFileEntry[]): string {
|
|
593
|
+
const hash = createHash('sha256');
|
|
594
|
+
const sortedFiles = files.slice().sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
595
|
+
for (const file of sortedFiles) {
|
|
596
|
+
hash.update(file.relativePath).update('\0');
|
|
597
|
+
hash.update(String(file.size)).update('\0');
|
|
598
|
+
try {
|
|
599
|
+
hash.update(fs.readFileSync(file.absolutePath));
|
|
600
|
+
} catch {
|
|
601
|
+
hash.update('read-error');
|
|
602
|
+
}
|
|
603
|
+
hash.update('\0');
|
|
604
|
+
}
|
|
605
|
+
return hash.digest('hex');
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function resolveSkillTrustLevel(sourceTag: string): SkillGuardTrustLevel {
|
|
609
|
+
const normalized = sourceTag.trim().toLowerCase();
|
|
610
|
+
if (normalized === 'bundled') return 'builtin';
|
|
611
|
+
if (normalized === 'workspace' || normalized === 'agents-project') return 'workspace';
|
|
612
|
+
if (
|
|
613
|
+
normalized === 'codex' ||
|
|
614
|
+
normalized === 'claude' ||
|
|
615
|
+
normalized === 'agents-personal' ||
|
|
616
|
+
normalized === 'extra'
|
|
617
|
+
) {
|
|
618
|
+
return 'personal';
|
|
619
|
+
}
|
|
620
|
+
if (normalized === 'community') return 'community';
|
|
621
|
+
return 'community';
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function scanSkillWithCache(params: {
|
|
625
|
+
skillName: string;
|
|
626
|
+
skillPath: string;
|
|
627
|
+
sourceTag: string;
|
|
628
|
+
trustLevel: SkillGuardTrustLevel;
|
|
629
|
+
}): SkillGuardScanResult {
|
|
630
|
+
const cacheKey = safeRealPath(params.skillPath);
|
|
631
|
+
const structure = collectStructure(params.skillPath);
|
|
632
|
+
const mtimeSignature = computeMtimeSignature(structure.signatureParts);
|
|
633
|
+
|
|
634
|
+
const cached = scanCache.get(cacheKey);
|
|
635
|
+
if (cached && cached.mtimeSignature === mtimeSignature) {
|
|
636
|
+
return {
|
|
637
|
+
...cached.result,
|
|
638
|
+
fromCache: true,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const contentHash = computeContentHash(structure.files);
|
|
643
|
+
if (cached && cached.contentHash === contentHash) {
|
|
644
|
+
const updatedCache: ScanCacheEntry = {
|
|
645
|
+
...cached,
|
|
646
|
+
mtimeSignature,
|
|
647
|
+
};
|
|
648
|
+
scanCache.set(cacheKey, updatedCache);
|
|
649
|
+
return {
|
|
650
|
+
...cached.result,
|
|
651
|
+
fromCache: true,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const findings: SkillGuardFinding[] = [
|
|
656
|
+
...structure.findings,
|
|
657
|
+
...structure.files.flatMap((file) => scanFile(file)),
|
|
658
|
+
];
|
|
659
|
+
|
|
660
|
+
const result: SkillGuardScanResult = {
|
|
661
|
+
skillName: params.skillName,
|
|
662
|
+
skillPath: params.skillPath,
|
|
663
|
+
sourceTag: params.sourceTag,
|
|
664
|
+
trustLevel: params.trustLevel,
|
|
665
|
+
verdict: determineVerdict(findings),
|
|
666
|
+
findings,
|
|
667
|
+
scannedAt: new Date().toISOString(),
|
|
668
|
+
summary: buildSummary({
|
|
669
|
+
skillName: params.skillName,
|
|
670
|
+
verdict: determineVerdict(findings),
|
|
671
|
+
findings,
|
|
672
|
+
}),
|
|
673
|
+
fromCache: false,
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
scanCache.set(cacheKey, {
|
|
677
|
+
mtimeSignature,
|
|
678
|
+
contentHash,
|
|
679
|
+
result,
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
return result;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function shouldAllowByPolicy(result: SkillGuardScanResult): { allowed: boolean; reason: string } {
|
|
686
|
+
const decision = INSTALL_POLICY[result.trustLevel][result.verdict];
|
|
687
|
+
if (decision === 'allow') {
|
|
688
|
+
return {
|
|
689
|
+
allowed: true,
|
|
690
|
+
reason: `allowed (${result.trustLevel} source, ${result.verdict} verdict)`,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
return {
|
|
694
|
+
allowed: false,
|
|
695
|
+
reason: `blocked (${result.trustLevel} source + ${result.verdict} verdict, ${result.findings.length} finding(s))`,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
export function guardSkillDirectory(params: {
|
|
700
|
+
skillName: string;
|
|
701
|
+
skillPath: string;
|
|
702
|
+
sourceTag: string;
|
|
703
|
+
}): SkillGuardDecision {
|
|
704
|
+
const trustLevel = resolveSkillTrustLevel(params.sourceTag);
|
|
705
|
+
if (trustLevel === 'builtin') {
|
|
706
|
+
const result: SkillGuardScanResult = {
|
|
707
|
+
skillName: params.skillName,
|
|
708
|
+
skillPath: params.skillPath,
|
|
709
|
+
sourceTag: params.sourceTag,
|
|
710
|
+
trustLevel,
|
|
711
|
+
verdict: 'safe',
|
|
712
|
+
findings: [],
|
|
713
|
+
scannedAt: new Date().toISOString(),
|
|
714
|
+
summary: `${params.skillName}: builtin source, scan skipped`,
|
|
715
|
+
fromCache: false,
|
|
716
|
+
};
|
|
717
|
+
return {
|
|
718
|
+
allowed: true,
|
|
719
|
+
reason: 'allowed (builtin source, scan skipped)',
|
|
720
|
+
result,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const result = scanSkillWithCache({
|
|
725
|
+
skillName: params.skillName,
|
|
726
|
+
skillPath: params.skillPath,
|
|
727
|
+
sourceTag: params.sourceTag,
|
|
728
|
+
trustLevel,
|
|
729
|
+
});
|
|
730
|
+
const decision = shouldAllowByPolicy(result);
|
|
731
|
+
return {
|
|
732
|
+
allowed: decision.allowed,
|
|
733
|
+
reason: decision.reason,
|
|
734
|
+
result,
|
|
735
|
+
};
|
|
736
|
+
}
|