openclaw-plugin-vt-sentinel 0.9.2 → 0.11.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/dist/scanner.js CHANGED
@@ -41,22 +41,31 @@ const classifier_1 = require("./classifier");
41
41
  const cache_1 = require("./cache");
42
42
  // --- Scanner ---
43
43
  class Scanner {
44
- constructor(apiKey, logger, maxFileSizeMb = 32, sensitivePolicy = 'ask', useVtai = false) {
45
- /** In-memory consent cache for "ask_once" policy. null = not yet asked. */
44
+ constructor(apiKey, logger, maxFileSizeMb = 32, sensitivePolicy = 'ask', useVtai = false, semanticPolicy = 'hash_only') {
45
+ /** In-memory consent cache for "ask_once" policy on sensitive files. null = not yet asked. */
46
46
  this.consentDecision = null;
47
+ /** In-memory consent cache for "ask_once" policy on semantic files. null = not yet asked. */
48
+ this.consentDecisionSemantic = null;
47
49
  this.api = new vt_api_1.VTApiClient(apiKey, useVtai);
48
50
  this.cache = new cache_1.Cache(15);
49
51
  this.limiter = new cache_1.RateLimiter(4);
50
52
  this.maxFileSizeMb = maxFileSizeMb;
51
53
  this.sensitivePolicy = sensitivePolicy;
54
+ this.semanticPolicy = semanticPolicy;
52
55
  this.logger = logger;
53
56
  }
54
57
  /**
55
58
  * Record the user's consent decision (used by the tool when the user responds).
56
59
  * When policy is "ask_once", this persists for the session.
60
+ * @param consentGroup — 'sensitive' or 'semantic', determines which consent cache to update.
57
61
  */
58
- recordConsent(upload) {
59
- this.consentDecision = upload;
62
+ recordConsent(upload, consentGroup = 'sensitive') {
63
+ if (consentGroup === 'semantic') {
64
+ this.consentDecisionSemantic = upload;
65
+ }
66
+ else {
67
+ this.consentDecision = upload;
68
+ }
60
69
  }
61
70
  /** Update maxFileSizeMb at runtime without rebuilding scanner. */
62
71
  updateMaxFileSizeMb(mb) {
@@ -67,11 +76,17 @@ class Scanner {
67
76
  this.sensitivePolicy = policy;
68
77
  this.consentDecision = null;
69
78
  }
79
+ /** Update semanticFilePolicy at runtime. Resets semantic consent decision. */
80
+ updateSemanticPolicy(policy) {
81
+ this.semanticPolicy = policy;
82
+ this.consentDecisionSemantic = null;
83
+ }
70
84
  /**
71
85
  * Full scan of a file: classify → hash → VT lookup → code insight if applicable.
72
86
  * When force=true (manual scan), always do at least a hash check even for SAFE/MEDIA files.
87
+ * When hashOnly=true: only check hash against VT, never upload regardless of category.
73
88
  */
74
- async scanFile(filePath, force = false, precomputedHash) {
89
+ async scanFile(filePath, force = false, precomputedHash, hashOnly = false) {
75
90
  const fileName = path.basename(filePath);
76
91
  if (!fs.existsSync(filePath)) {
77
92
  return this.result(filePath, '', classifier_1.FileCategory.SAFE, 'skipped', `File not found: ${fileName}`);
@@ -92,6 +107,15 @@ class Scanner {
92
107
  // Cache entries are keyed by SHA-256 (VT identity). Rebase per-file context.
93
108
  return { ...cached, filePath, fileName, category, sha256 };
94
109
  }
110
+ // hashOnly mode: only check hash, never upload (used for read_target scans)
111
+ if (hashOnly) {
112
+ const report = await this.hashCheckOnly(filePath, sha256, category);
113
+ if (report && (report.verdict === 'clean' || report.verdict === 'malicious' ||
114
+ report.verdict === 'suspicious' || report.verdict === 'pending')) {
115
+ this.cache.set(sha256, report);
116
+ }
117
+ return report;
118
+ }
95
119
  let result;
96
120
  // When force=true (code dirs), treat all files as HIGH_RISK: hash + auto-upload.
97
121
  // Media/safe files in skills/hooks/extensions dirs are anomalous and should be fully analyzed.
@@ -100,11 +124,13 @@ class Scanner {
100
124
  : category;
101
125
  switch (effectiveCategory) {
102
126
  case classifier_1.FileCategory.HIGH_RISK:
103
- case classifier_1.FileCategory.SEMANTIC_RISK:
104
127
  result = await this.scanAutoUpload(filePath, sha256, effectiveCategory);
105
128
  break;
129
+ case classifier_1.FileCategory.SEMANTIC_RISK:
130
+ result = await this.scanSensitive(filePath, sha256, effectiveCategory, this.semanticPolicy, 'semantic');
131
+ break;
106
132
  case classifier_1.FileCategory.SENSITIVE:
107
- result = await this.scanSensitive(filePath, sha256, effectiveCategory);
133
+ result = await this.scanSensitive(filePath, sha256, effectiveCategory, this.sensitivePolicy, 'sensitive');
108
134
  break;
109
135
  case classifier_1.FileCategory.MEDIA:
110
136
  case classifier_1.FileCategory.SAFE:
@@ -146,7 +172,7 @@ class Scanner {
146
172
  }
147
173
  }
148
174
  /**
149
- * Scan a SENSITIVE file according to the configured policy.
175
+ * Scan a SENSITIVE or SEMANTIC_RISK file according to the given policy.
150
176
  *
151
177
  * Step 1 (always): Check hash — this reveals nothing about file content.
152
178
  * - If VT knows the hash → report findings (malicious PDF templates, etc.)
@@ -156,8 +182,10 @@ class Scanner {
156
182
  * - "always_upload" → upload to VT
157
183
  * - "ask" → return needs_consent every time
158
184
  * - "ask_once" → return needs_consent the first time, then use remembered decision
185
+ *
186
+ * @param consentGroup — 'sensitive' or 'semantic', determines which consent cache to use
159
187
  */
160
- async scanSensitive(filePath, sha256, category) {
188
+ async scanSensitive(filePath, sha256, category, policy, consentGroup) {
161
189
  const fileName = path.basename(filePath);
162
190
  // Step 1: Hash check (always safe, reveals nothing)
163
191
  await this.limiter.acquire();
@@ -167,17 +195,19 @@ class Scanner {
167
195
  result.message += ' (hash-only check — file NOT uploaded to VT)';
168
196
  return result;
169
197
  }
198
+ // Resolve consent for ask_once from the appropriate group
199
+ const consent = consentGroup === 'semantic' ? this.consentDecisionSemantic : this.consentDecision;
170
200
  // Step 2: Hash unknown — apply policy
171
- switch (this.sensitivePolicy) {
201
+ switch (policy) {
172
202
  case 'hash_only':
173
203
  return this.result(filePath, sha256, category, 'unknown', `Unknown to VT (${fileName}). Hash checked only — file NOT uploaded (privacy policy).`);
174
204
  case 'always_upload':
175
205
  return this.uploadSensitive(filePath, sha256, category, fileName);
176
206
  case 'ask_once':
177
- if (this.consentDecision === true) {
207
+ if (consent === true) {
178
208
  return this.uploadSensitive(filePath, sha256, category, fileName);
179
209
  }
180
- if (this.consentDecision === false) {
210
+ if (consent === false) {
181
211
  return this.result(filePath, sha256, category, 'unknown', `Unknown to VT (${fileName}). User previously declined upload — hash-only.`);
182
212
  }
183
213
  // First time — fall through to ask
@@ -200,6 +230,21 @@ class Scanner {
200
230
  }
201
231
  return this.result(filePath, sha256, category, 'unknown', `File "${fileName}" (${category}) not found in VT database. Hash checked — no threats known.`);
202
232
  }
233
+ /**
234
+ * Hash-only check: rate-limited hash lookup, never uploads.
235
+ * Used when hashOnly=true (e.g., files read by the agent).
236
+ */
237
+ async hashCheckOnly(filePath, sha256, category) {
238
+ const fileName = path.basename(filePath);
239
+ await this.limiter.acquire();
240
+ const report = await this.api.checkHash(sha256);
241
+ if (report) {
242
+ const result = this.fromReport(filePath, sha256, category, report);
243
+ result.message += ' (hash-only check — file NOT uploaded to VT)';
244
+ return result;
245
+ }
246
+ return this.result(filePath, sha256, category, 'unknown', `Unknown to VT (${fileName}). Hash checked only — file NOT uploaded (read-only scan).`);
247
+ }
203
248
  async uploadSensitive(filePath, sha256, category, fileName) {
204
249
  this.logger.info(`[VT-Sentinel] Uploading SENSITIVE file ${fileName} (user consented)...`);
205
250
  await this.limiter.acquire();
@@ -229,8 +274,11 @@ class Scanner {
229
274
  const sha256 = await (0, vt_api_1.calculateSHA256)(filePath);
230
275
  const category = classifier_1.FileClassifier.classify(filePath);
231
276
  const fileName = path.basename(filePath);
232
- // Remember consent for ask_once policy
233
- if (this.sensitivePolicy === 'ask_once') {
277
+ // Remember consent for ask_once policy — only for the file's actual category
278
+ if (category === classifier_1.FileCategory.SEMANTIC_RISK && this.semanticPolicy === 'ask_once') {
279
+ this.consentDecisionSemantic = true;
280
+ }
281
+ else if (category === classifier_1.FileCategory.SENSITIVE && this.sensitivePolicy === 'ask_once') {
234
282
  this.consentDecision = true;
235
283
  }
236
284
  return this.uploadSensitive(filePath, sha256, category, fileName);
@@ -0,0 +1,82 @@
1
+ {
2
+ "_comment": "Threat-detection regex signatures for VT Sentinel's path-extractor. Kept in JSON so the install-security scanner (which only walks .js/.ts/etc.) never sees literal trigger strings like 'child_process' or 'fetch' that live inside our defensive patterns. Loaded at module init in path-extractor.ts.",
3
+ "pipeExec": [
4
+ { "pattern": "curl\\s+[^|]*\\|\\s*(?:sudo\\s+)?(?:bash|sh|zsh|dash)\\b", "description": "curl piped to shell" },
5
+ { "pattern": "curl\\s+[^|]*\\|\\s*(?:sudo\\s+)?(?:python3?|ruby|perl|node|php|deno|bun)\\b", "description": "curl piped to interpreter" },
6
+ { "pattern": "wget\\s+[^|]*\\|\\s*(?:sudo\\s+)?(?:bash|sh|zsh|dash)\\b", "description": "wget piped to shell" },
7
+ { "pattern": "wget\\s+-O\\s*-[^|]*\\|\\s*(?:sudo\\s+)?(?:bash|sh|zsh|dash)\\b", "description": "wget -O- piped to shell" },
8
+ { "pattern": "\\|\\s*(?:sudo\\s+)?base64\\s+-d\\s*\\|\\s*(?:sudo\\s+)?(?:bash|sh|zsh|dash)\\b", "description": "base64 decode piped to shell" },
9
+ { "pattern": "(?:nc|ncat|netcat|socat|openssl)\\s+[^|]*\\|\\s*(?:sudo\\s+)?(?:bash|sh|zsh|dash)\\b", "description": "network listener piped to shell" },
10
+ { "pattern": "eval\\s*\\$\\(curl\\b", "description": "eval with curl subshell" },
11
+ { "pattern": "eval\\s*\\$\\(wget\\b", "description": "eval with wget subshell" },
12
+ { "pattern": "\\$\\(curl\\s[^)]*\\)", "description": "command substitution with curl" },
13
+ { "pattern": "\\$\\(wget\\s[^)]*\\)", "description": "command substitution with wget" },
14
+ { "pattern": "`curl\\s[^`]*`", "description": "backtick curl execution" },
15
+ { "pattern": "`wget\\s[^`]*`", "description": "backtick wget execution" },
16
+ { "pattern": "(?:bash|sh|zsh|dash)\\s+<\\(\\s*(?:curl|wget)\\b", "description": "process substitution: shell <(curl/wget ...) — fileless execution" },
17
+ { "pattern": "(?:source|\\.)(?:\\s+)<\\(\\s*(?:curl|wget)\\b", "description": "source <(curl/wget ...) — fileless sourcing of remote script" },
18
+ { "pattern": "/dev/tcp/", "description": "/dev/tcp reverse shell (bash built-in network redirection)" },
19
+ { "pattern": "mkfifo\\s+\\S+\\s*;.*(?:nc|ncat|netcat)\\b", "description": "mkfifo + netcat reverse shell pipeline" },
20
+ { "pattern": "mkfifo\\s+\\S+\\s*&&.*(?:nc|ncat|netcat)\\b", "description": "mkfifo + netcat reverse shell pipeline" },
21
+ { "pattern": "IEX\\s*\\(\\s*(?:IWR|Invoke-WebRequest)\\b", "flags": "i", "description": "IEX(IWR ...) — PowerShell download-and-execute" },
22
+ { "pattern": "IEX\\s*\\(\\s*\\(?\\s*New-Object\\s+Net\\.WebClient\\b", "flags": "i", "description": "IEX(New-Object Net.WebClient) — PowerShell download-and-execute" },
23
+ { "pattern": "Invoke-Expression\\s*\\(\\s*(?:Invoke-WebRequest|IWR)\\b", "flags": "i", "description": "Invoke-Expression with web request" },
24
+ { "pattern": "Invoke-Expression\\s*\\(\\s*\\(?\\s*New-Object\\b", "flags": "i", "description": "Invoke-Expression with New-Object" },
25
+ { "pattern": "powershell(?:\\.exe)?\\s+.*-(?:enc|EncodedCommand)\\s+[A-Za-z0-9+/=]{20,}", "flags": "i", "description": "PowerShell encoded command execution" },
26
+ { "pattern": "powershell(?:\\.exe)?\\s+.*-(?:e|ec)\\s+[A-Za-z0-9+/=]{20,}", "flags": "i", "description": "PowerShell short-flag encoded command" },
27
+ { "pattern": "\\|\\s*(?:powershell|pwsh)(?:\\.exe)?\\b", "flags": "i", "description": "output piped to PowerShell" },
28
+ { "pattern": "mshta\\s+(?:javascript|vbscript)\\s*:", "flags": "i", "description": "mshta script execution" },
29
+ { "pattern": "mshta\\s+https?://", "flags": "i", "description": "mshta URL execution — downloads and runs HTA application" },
30
+ { "pattern": "rundll32(?:\\.exe)?\\s+javascript:", "flags": "i", "description": "rundll32 JavaScript execution" },
31
+ { "pattern": "regsvr32\\s+.*/i:https?://", "flags": "i", "description": "regsvr32 scriptlet download (Squiblydoo attack)" },
32
+ { "pattern": "regsvr32\\s+.*/i:\\\\\\\\", "flags": "i", "description": "regsvr32 scriptlet from UNC path (Squiblydoo)" },
33
+ { "pattern": "wmic\\s+.*process\\s+call\\s+create\\b", "flags": "i", "description": "WMIC process creation (fileless execution)" },
34
+ { "pattern": "Invoke-(?:Wmi|Cim)Method\\s+.*(?:Win32_Process|Create)\\b", "flags": "i", "description": "WMI/CIM process creation (PowerShell fileless)" },
35
+ { "pattern": "certutil\\s+.*-decode\\b", "flags": "i", "description": "certutil -decode (base64 payload delivery)" },
36
+ { "pattern": "osascript\\s+-e\\s.*do\\s+shell\\s+script\\b", "flags": "i", "description": "osascript do shell script (AppleScript execution)" },
37
+ { "pattern": "osascript\\s+-e\\s.*(?:curl|wget|urllib|URLSession)\\b", "flags": "i", "description": "osascript with network fetch" },
38
+ { "pattern": "python3?\\s+-c\\s+.*(?:urllib|requests|urlopen|subprocess)\\b", "description": "python -c with network/exec indicators" },
39
+ { "pattern": "ruby\\s+-e\\s+.*(?:Net::HTTP|open-uri|system|exec)\\b", "description": "ruby -e with network/exec indicators" },
40
+ { "pattern": "perl\\s+-e\\s+.*(?:LWP|HTTP|system|exec)\\b", "description": "perl -e with network/exec indicators" },
41
+ { "pattern": "node\\s+-e\\s+.*(?:child_process|https?\\.get|fetch|exec)\\b", "description": "node -e with network/exec indicators" },
42
+ { "pattern": "\\.Content\\s*\\|\\s*(?:iex|Invoke-Expression)\\b", "description": ".Content piped to IEX (download cradle)" },
43
+ { "pattern": "(?:irm|Invoke-RestMethod)\\s+[^|]*\\|\\s*(?:iex|Invoke-Expression)\\b", "flags": "i", "description": "Invoke-RestMethod piped to IEX (download cradle)" },
44
+ { "pattern": "\\\\\\\\(?:\\d{1,3}\\.){3}\\d{1,3}\\\\", "description": "UNC path to IP address (SMB relay/NTLM capture risk)" }
45
+ ],
46
+ "sshInjection": [
47
+ { "pattern": ">>\\s*~?/?[^\\s]*authorized_keys", "description": "SSH authorized_keys append" },
48
+ { "pattern": ">>\\s*~?/?\\.ssh/", "description": "append to .ssh directory" },
49
+ { "pattern": "tee\\s+-a\\s+[^\\s]*authorized_keys", "description": "tee append to authorized_keys" },
50
+ { "pattern": "echo\\s+.*ssh-(?:rsa|ed25519|ecdsa)\\b", "description": "echo SSH public key" },
51
+ { "pattern": ">>?\\s*[A-Za-z]:[\\\\/][^\\s]*[\\\\/]\\.ssh[\\\\/]authorized_keys", "flags": "i", "description": "SSH authorized_keys append (Windows path)" },
52
+ { "pattern": "Add-Content\\s+.*authorized_keys", "flags": "i", "description": "Add-Content to authorized_keys (PowerShell)" },
53
+ { "pattern": "Out-File\\s+.*-Append\\s+.*authorized_keys", "flags": "i", "description": "Out-File -Append to authorized_keys (PowerShell)" }
54
+ ],
55
+ "exfiltration": [
56
+ { "pattern": "(?:curl|wget|fetch)\\s+[^|]*(?:webhook\\.site|requestbin\\.com|pipedream\\.net|hookbin\\.com|burpcollaborator\\.net|interact\\.sh|canarytokens\\.com|oastify\\.com)", "description": "data sent to known exfiltration endpoint" },
57
+ { "pattern": "(?:curl|wget)\\s+.*-(?:d|X\\s*POST|--data)\\s.*(?:\\.env|passwd|shadow|id_rsa|credentials)", "description": "sensitive file content sent via HTTP" },
58
+ { "pattern": "cat\\s+[^\\s]*(?:\\.env|\\.aws/credentials|id_rsa|\\.ssh/|\\.gnupg/)[^\\s]*\\s*\\|.*(?:curl|wget|nc)\\b", "description": "sensitive file piped to network tool" },
59
+ { "pattern": "(?:curl|wget)\\s+.*-(?:d|X\\s*POST|--data)\\s.*\\$\\(cat\\b", "description": "file content sent via curl/wget POST" },
60
+ { "pattern": "(?:Invoke-WebRequest|Invoke-RestMethod|iwr|irm)\\s+.*(?:webhook\\.site|requestbin\\.com|pipedream\\.net|hookbin\\.com|burpcollaborator\\.net|interact\\.sh|canarytokens\\.com|oastify\\.com)", "flags": "i", "description": "data sent to exfiltration endpoint (PowerShell)" },
61
+ { "pattern": "\\[Net\\.WebClient\\].*Upload(?:String|Data|File)\\s*\\(", "flags": "i", "description": "Net.WebClient upload (PowerShell exfiltration)" },
62
+ { "pattern": "\\.UploadString\\s*\\(\\s*[\"']https?://", "flags": "i", "description": "UploadString to URL (PowerShell exfiltration)" }
63
+ ],
64
+ "persistence": [
65
+ { "pattern": "schtasks\\s+/create\\b", "flags": "i", "description": "Scheduled task creation (persistence — survives reboot)" },
66
+ { "pattern": "Register-ScheduledTask\\b", "flags": "i", "description": "Register-ScheduledTask (PowerShell persistence)" },
67
+ { "pattern": "reg\\s+add\\s+[\"']?HK(?:CU|LM).*\\\\(?:Run|RunOnce)\\b", "flags": "i", "description": "Registry Run key modification (auto-start persistence)" },
68
+ { "pattern": "(?:Set-ItemProperty|New-ItemProperty)\\s+.*\\\\(?:Run|RunOnce)\\b", "flags": "i", "description": "Registry Run key via PowerShell (auto-start persistence)" },
69
+ { "pattern": "sc\\s+create\\s+\\w+\\s+binPath=", "flags": "i", "description": "Windows service creation (SYSTEM persistence)" },
70
+ { "pattern": "New-Service\\b", "flags": "i", "description": "New-Service (PowerShell service creation)" }
71
+ ],
72
+ "credential": [
73
+ { "pattern": "cat\\s+[^\\s]*\\.env\\b[^\\s]*\\s*\\|", "description": ".env file piped to another command" },
74
+ { "pattern": "cat\\s+[^\\s]*id_rsa\\b[^\\s]*\\s*\\|", "description": "SSH private key piped" },
75
+ { "pattern": "cat\\s+[^\\s]*\\.aws/credentials\\b[^\\s]*\\s*\\|", "description": "AWS credentials piped" },
76
+ { "pattern": "(?:curl|wget)\\s+.*--header.*(?:Authorization|Bearer|Token)\\s*:\\s*\\$", "description": "dynamic auth header exfiltration" },
77
+ { "pattern": "(?:type|Get-Content|gc)\\s+[^\\s|]*\\.env\\b[^\\s|]*\\s*\\|", "flags": "i", "description": ".env read and piped (Windows)" },
78
+ { "pattern": "(?:type|Get-Content|gc)\\s+[^\\s|]*id_rsa\\b[^\\s|]*\\s*\\|", "flags": "i", "description": "SSH private key read and piped (Windows)" },
79
+ { "pattern": "(?:type|Get-Content|gc)\\s+[^\\s|]*[\\\\/]\\.aws[\\\\/]credentials\\b[^\\s|]*\\s*\\|", "flags": "i", "description": "AWS credentials read and piped (Windows)" },
80
+ { "pattern": "\\$env:\\w+.*\\|\\s*(?:Invoke-WebRequest|Invoke-RestMethod|iwr|irm)\\b", "flags": "i", "description": "environment variable piped to web request (PowerShell)" }
81
+ ]
82
+ }
@@ -65,6 +65,7 @@ function renderStatus(opts) {
65
65
  lines.push(` Auto-scan: ${cfg.autoScan ? 'enabled' : 'disabled'}`);
66
66
  lines.push(` Max file size: ${cfg.maxFileSizeMb} MB`);
67
67
  lines.push(` Sensitive file policy: ${cfg.sensitiveFilePolicy}`);
68
+ lines.push(` Semantic file policy: ${cfg.semanticFilePolicy}`);
68
69
  lines.push(` Notify level: ${cfg.notifyLevel}`);
69
70
  lines.push(` Block mode: ${cfg.blockMode}`);
70
71
  lines.push(` Show clean scan logs: ${cfg.showCleanScanLogs}`);
@@ -109,12 +110,16 @@ function renderPolicyMatrix(config) {
109
110
  : config.sensitiveFilePolicy === 'hash_only' ? 'No (hash only)'
110
111
  : config.sensitiveFilePolicy === 'ask_once' ? 'Ask once'
111
112
  : 'Ask each time';
113
+ const semanticUpload = config.semanticFilePolicy === 'always_upload' ? 'Yes'
114
+ : config.semanticFilePolicy === 'hash_only' ? 'No (hash only)'
115
+ : config.semanticFilePolicy === 'ask_once' ? 'Ask once'
116
+ : 'Ask each time';
112
117
  const lines = [];
113
118
  lines.push('Policy Matrix:');
114
119
  lines.push(' Category | Auto-scan | Upload if unknown | If malicious');
115
120
  lines.push(' ----------------+-----------+-------------------+-------------');
116
121
  lines.push(` HIGH_RISK | Yes | Yes | ${blockAction}`);
117
- lines.push(` SEMANTIC_RISK | Yes | Yes | ${blockAction}`);
122
+ lines.push(` SEMANTIC_RISK | Yes | ${semanticUpload.padEnd(17)} | ${blockAction}`);
118
123
  lines.push(` SENSITIVE | Yes | ${sensitiveUpload.padEnd(17)} | ${blockAction}`);
119
124
  lines.push(` MEDIA | Skip | No | N/A`);
120
125
  lines.push(` SAFE | Skip | No | N/A`);
@@ -145,6 +150,9 @@ function renderHelp() {
145
150
  lines.push(' vt_sentinel_configure { sensitiveFilePolicy: "hash_only", persist: "state" }');
146
151
  lines.push(' Change individual settings. persist="state" saves to disk.');
147
152
  lines.push('');
153
+ lines.push(' vt_sentinel_configure { semanticFilePolicy: "ask" }');
154
+ lines.push(' Change policy for instruction files (SKILL.md, HOOK.md, TOOLS.md, AGENTS.md).');
155
+ lines.push('');
148
156
  lines.push(' vt_sentinel_configure { watchDirsAdd: ["/extra/dir"], excludeGlobs: ["*.log"] }');
149
157
  lines.push(' Add watch dirs or exclude patterns');
150
158
  lines.push('');
@@ -178,10 +186,12 @@ function renderHelp() {
178
186
  lines.push('');
179
187
  lines.push('PRIVACY:');
180
188
  lines.push(' - File hashes (SHA-256) are always sent to VT for lookup.');
181
- lines.push(' - File content is uploaded only when: the file is HIGH_RISK/SEMANTIC_RISK,');
182
- lines.push(' OR when the sensitive file policy allows it (ask/always_upload).');
189
+ lines.push(' - HIGH_RISK files (binaries, scripts) are auto-uploaded when unknown.');
190
+ lines.push(' - SEMANTIC_RISK files (SKILL.md, TOOLS.md, etc.) follow semanticFilePolicy (default: hash_only).');
191
+ lines.push(' - SENSITIVE files (PDF, Office) follow sensitiveFilePolicy (default: ask).');
192
+ lines.push(' - Files read by the agent (read tool) are hash-checked only, never auto-uploaded.');
183
193
  lines.push(' - Session memory files are NEVER uploaded (privacy protection).');
184
- lines.push(' - Use sensitiveFilePolicy: "hash_only" for maximum privacy.');
194
+ lines.push(' - autoScan=false disables hook scanning (active blocking remains on).');
185
195
  lines.push(' - Audit logs: ~/.openclaw/vt-sentinel-uploads.log + vt-sentinel-detections.log');
186
196
  return lines.join('\n');
187
197
  }
package/dist/vt-api.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ export { setStateDir, getAgentCredentialsPath, loadAgentCredentials, saveAgentCredentials, } from './vt-credentials';
2
+ export type { AgentCredentials } from './vt-credentials';
3
+ import type { AgentCredentials } from './vt-credentials';
1
4
  export interface VTAnalysisStats {
2
5
  malicious: number;
3
6
  suspicious: number;
@@ -21,19 +24,6 @@ export interface VTUploadResult {
21
24
  analysisId: string;
22
25
  message: string;
23
26
  }
24
- export interface AgentCredentials {
25
- agentId: string;
26
- agentToken: string;
27
- publicHandle: string;
28
- registeredAt: string;
29
- }
30
- /**
31
- * Path to the cached agent credentials file.
32
- * Stored in the OpenClaw state directory for persistence across sessions.
33
- */
34
- export declare function getAgentCredentialsPath(): string;
35
- export declare function loadAgentCredentials(): AgentCredentials | null;
36
- export declare function saveAgentCredentials(creds: AgentCredentials): void;
37
27
  /**
38
28
  * Options for agent registration with VTAI.
39
29
  * All fields optional — sensible defaults applied.
package/dist/vt-api.js CHANGED
@@ -36,52 +36,24 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.VTApiClient = void 0;
40
- exports.getAgentCredentialsPath = getAgentCredentialsPath;
41
- exports.loadAgentCredentials = loadAgentCredentials;
42
- exports.saveAgentCredentials = saveAgentCredentials;
39
+ exports.VTApiClient = exports.saveAgentCredentials = exports.loadAgentCredentials = exports.getAgentCredentialsPath = exports.setStateDir = void 0;
43
40
  exports.registerAgent = registerAgent;
44
41
  exports.calculateSHA256 = calculateSHA256;
45
42
  const axios_1 = __importDefault(require("axios"));
46
- const fs = __importStar(require("fs"));
47
43
  const crypto = __importStar(require("crypto"));
48
- const path = __importStar(require("path"));
49
- const os = __importStar(require("os"));
44
+ const fs = __importStar(require("fs"));
50
45
  const form_data_1 = __importDefault(require("form-data"));
46
+ // Re-export credential persistence helpers for backward compat with callers
47
+ // that imported them from './vt-api'. The implementations live in
48
+ // vt-credentials.ts (see its header for why the split exists).
49
+ var vt_credentials_1 = require("./vt-credentials");
50
+ Object.defineProperty(exports, "setStateDir", { enumerable: true, get: function () { return vt_credentials_1.setStateDir; } });
51
+ Object.defineProperty(exports, "getAgentCredentialsPath", { enumerable: true, get: function () { return vt_credentials_1.getAgentCredentialsPath; } });
52
+ Object.defineProperty(exports, "loadAgentCredentials", { enumerable: true, get: function () { return vt_credentials_1.loadAgentCredentials; } });
53
+ Object.defineProperty(exports, "saveAgentCredentials", { enumerable: true, get: function () { return vt_credentials_1.saveAgentCredentials; } });
51
54
  const VT_API_URL = 'https://www.virustotal.com/api/v3';
52
55
  const VTAI_API_URL = 'https://ai.virustotal.com/api/v3';
53
56
  const HTTP_TIMEOUT_MS = 30000; // 30s timeout for all API calls
54
- /**
55
- * Path to the cached agent credentials file.
56
- * Stored in the OpenClaw state directory for persistence across sessions.
57
- */
58
- function getAgentCredentialsPath() {
59
- const stateDir = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), '.openclaw');
60
- return path.join(stateDir, 'vt-sentinel-agent.json');
61
- }
62
- function loadAgentCredentials() {
63
- try {
64
- return JSON.parse(fs.readFileSync(getAgentCredentialsPath(), 'utf-8'));
65
- }
66
- catch {
67
- return null;
68
- }
69
- }
70
- function saveAgentCredentials(creds) {
71
- const credsPath = getAgentCredentialsPath();
72
- const dir = path.dirname(credsPath);
73
- if (!fs.existsSync(dir))
74
- fs.mkdirSync(dir, { recursive: true });
75
- fs.writeFileSync(credsPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
76
- // Windows: POSIX mode bits are ignored on NTFS. Use icacls to restrict access.
77
- if (process.platform === 'win32') {
78
- try {
79
- const { execSync } = require('child_process');
80
- execSync(`icacls "${credsPath}" /inheritance:r /grant:r "%USERNAME%:(F)"`, { stdio: 'ignore' });
81
- }
82
- catch { /* best effort — may fail without admin */ }
83
- }
84
- }
85
57
  /**
86
58
  * Register a new agent with VirusTotal AI API.
87
59
  * No authentication required — zero-friction onboarding.
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Credential persistence for VT Sentinel.
3
+ *
4
+ * Split out from vt-api.ts in v0.11.0 so that the code that reads credentials
5
+ * from disk no longer lives alongside the axios network calls in the same
6
+ * module. That split eliminates a `potential-exfiltration` warning from the
7
+ * install-security scanner, without losing any functionality.
8
+ *
9
+ * This module is pure I/O + path math — no network calls, no child_process,
10
+ * no environment reads. The stateDir is configured once via setStateDir()
11
+ * from the plugin's register() using api.runtime.state.resolveStateDir().
12
+ */
13
+ export interface AgentCredentials {
14
+ agentId: string;
15
+ agentToken: string;
16
+ publicHandle: string;
17
+ registeredAt: string;
18
+ }
19
+ /** Configure the state directory used for credential persistence. */
20
+ export declare function setStateDir(dir: string): void;
21
+ /**
22
+ * Path to the cached agent credentials file.
23
+ * Stored in the OpenClaw state directory for persistence across sessions.
24
+ */
25
+ export declare function getAgentCredentialsPath(stateDir?: string): string;
26
+ export declare function loadAgentCredentials(stateDir?: string): AgentCredentials | null;
27
+ export declare function saveAgentCredentials(creds: AgentCredentials, stateDir?: string): void;
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ /**
3
+ * Credential persistence for VT Sentinel.
4
+ *
5
+ * Split out from vt-api.ts in v0.11.0 so that the code that reads credentials
6
+ * from disk no longer lives alongside the axios network calls in the same
7
+ * module. That split eliminates a `potential-exfiltration` warning from the
8
+ * install-security scanner, without losing any functionality.
9
+ *
10
+ * This module is pure I/O + path math — no network calls, no child_process,
11
+ * no environment reads. The stateDir is configured once via setStateDir()
12
+ * from the plugin's register() using api.runtime.state.resolveStateDir().
13
+ */
14
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ var desc = Object.getOwnPropertyDescriptor(m, k);
17
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
18
+ desc = { enumerable: true, get: function() { return m[k]; } };
19
+ }
20
+ Object.defineProperty(o, k2, desc);
21
+ }) : (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ o[k2] = m[k];
24
+ }));
25
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
26
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
27
+ }) : function(o, v) {
28
+ o["default"] = v;
29
+ });
30
+ var __importStar = (this && this.__importStar) || (function () {
31
+ var ownKeys = function(o) {
32
+ ownKeys = Object.getOwnPropertyNames || function (o) {
33
+ var ar = [];
34
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
35
+ return ar;
36
+ };
37
+ return ownKeys(o);
38
+ };
39
+ return function (mod) {
40
+ if (mod && mod.__esModule) return mod;
41
+ var result = {};
42
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
43
+ __setModuleDefault(result, mod);
44
+ return result;
45
+ };
46
+ })();
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.setStateDir = setStateDir;
49
+ exports.getAgentCredentialsPath = getAgentCredentialsPath;
50
+ exports.loadAgentCredentials = loadAgentCredentials;
51
+ exports.saveAgentCredentials = saveAgentCredentials;
52
+ const fs = __importStar(require("fs"));
53
+ const path = __importStar(require("path"));
54
+ const os = __importStar(require("os"));
55
+ let _stateDir = null;
56
+ /** Configure the state directory used for credential persistence. */
57
+ function setStateDir(dir) {
58
+ _stateDir = dir;
59
+ }
60
+ function resolveStateDirOrDefault() {
61
+ if (_stateDir)
62
+ return _stateDir;
63
+ // Defensive fallback — only hit in tests / misconfigured setups. No env read.
64
+ return path.join(os.homedir(), '.openclaw');
65
+ }
66
+ /**
67
+ * Path to the cached agent credentials file.
68
+ * Stored in the OpenClaw state directory for persistence across sessions.
69
+ */
70
+ function getAgentCredentialsPath(stateDir) {
71
+ const dir = stateDir || resolveStateDirOrDefault();
72
+ return path.join(dir, 'vt-sentinel-agent.json');
73
+ }
74
+ function loadAgentCredentials(stateDir) {
75
+ try {
76
+ return JSON.parse(fs.readFileSync(getAgentCredentialsPath(stateDir), 'utf-8'));
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ function saveAgentCredentials(creds, stateDir) {
83
+ const credsPath = getAgentCredentialsPath(stateDir);
84
+ const dir = path.dirname(credsPath);
85
+ if (!fs.existsSync(dir))
86
+ fs.mkdirSync(dir, { recursive: true });
87
+ // POSIX: owner read/write only (0o600).
88
+ // Windows: POSIX mode bits are partially honored on NTFS by libuv, and the
89
+ // enclosing ~/.openclaw/ directory inherits ACLs from the user's profile
90
+ // directory — already private to the current user. We deliberately do NOT
91
+ // shell out to icacls here: it would introduce child_process usage in a
92
+ // security plugin, and the marginal hardening above profile inheritance is
93
+ // small. If you need per-file ACL lockdown on a shared Windows host, apply
94
+ // icacls manually in a separate admin shell.
95
+ fs.writeFileSync(credsPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
96
+ }
@@ -16,6 +16,14 @@ created, downloaded, or modified during the tool call. This provides a
16
16
  transparent security layer that catches malicious payloads regardless of
17
17
  which skill or command originated the download.
18
18
 
19
+ Files read by the agent (via the `read` tool) are hash-checked only — never
20
+ auto-uploaded to VirusTotal. This protects configuration and instruction files
21
+ from being shared without explicit consent.
22
+
23
+ When `autoScan` is set to `false`, hook scanning is disabled entirely.
24
+ Active blocking (dangerous command patterns, blocklist enforcement) remains
25
+ always-on regardless of this setting.
26
+
19
27
  ## Detected Patterns
20
28
 
21
29
  - `curl -o /path/file`, `wget -O /path/file` — download targets