openclaw-plugin-vt-sentinel 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/config-manager.d.ts +2 -0
- package/dist/config-manager.js +17 -1
- package/dist/index.js +42 -16
- package/dist/scanner.d.ts +19 -5
- package/dist/scanner.js +62 -14
- package/dist/status-renderer.js +14 -4
- package/hooks/vt-auto-scan/HOOK.md +8 -0
- package/hooks/vt-auto-scan/handler.js +67 -6
- package/openclaw.plugin.json +7 -0
- package/package.json +1 -1
- package/skills/vt-sentinel/SKILL.md +6 -3
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ openclaw gateway restart
|
|
|
21
21
|
openclaw plugins list | grep vt-sentinel
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Should show
|
|
24
|
+
Should show 9 tools registered.
|
|
25
25
|
|
|
26
26
|
## Tools
|
|
27
27
|
|
|
@@ -35,10 +35,12 @@ Should show 8 tools registered.
|
|
|
35
35
|
| `vt_sentinel_reset_policy` | Reset all settings to defaults |
|
|
36
36
|
| `vt_sentinel_help` | Quick-start guide and privacy info |
|
|
37
37
|
| `vt_sentinel_update` | Check for updates and get upgrade instructions |
|
|
38
|
+
| `vt_sentinel_re_register` | Re-register agent identity with VTAI |
|
|
38
39
|
|
|
39
40
|
## What it does
|
|
40
41
|
|
|
41
42
|
- Scans downloaded and created files automatically (AV + AI Code Insight)
|
|
43
|
+
- Protects instruction files (SKILL.md, TOOLS.md) from being uploaded without consent
|
|
42
44
|
- Blocks execution of malicious files and dangerous command patterns
|
|
43
45
|
- Monitors directories in real-time (Downloads, /tmp, workspace)
|
|
44
46
|
- Quarantines threats with rotating audit logs
|
|
@@ -83,6 +85,7 @@ openclaw plugins config openclaw-plugin-vt-sentinel apiKey YOUR_KEY
|
|
|
83
85
|
| `notifyLevel` | all, threats_only, silent | all |
|
|
84
86
|
| `blockMode` | quarantine, block_only, log_only | quarantine |
|
|
85
87
|
| `sensitiveFilePolicy` | ask, ask_once, always_upload, hash_only | ask |
|
|
88
|
+
| `semanticFilePolicy` | ask, ask_once, always_upload, hash_only | hash_only |
|
|
86
89
|
| `maxFileSizeMb` | 1-32 | 32 |
|
|
87
90
|
| `autoScan` | true, false | true |
|
|
88
91
|
|
package/dist/config-manager.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface FullConfig {
|
|
|
9
9
|
autoScan: boolean;
|
|
10
10
|
maxFileSizeMb: number;
|
|
11
11
|
sensitiveFilePolicy: SensitiveFilePolicy;
|
|
12
|
+
semanticFilePolicy: SensitiveFilePolicy;
|
|
12
13
|
notifyLevel: NotifyLevel;
|
|
13
14
|
excludeDirs: string[];
|
|
14
15
|
excludeGlobs: string[];
|
|
@@ -33,6 +34,7 @@ export interface StaticConfig {
|
|
|
33
34
|
autoScan?: boolean;
|
|
34
35
|
maxFileSizeMb?: number;
|
|
35
36
|
sensitiveFilePolicy?: SensitiveFilePolicy;
|
|
37
|
+
semanticFilePolicy?: SensitiveFilePolicy;
|
|
36
38
|
notifyLevel?: NotifyLevel;
|
|
37
39
|
excludeDirs?: string[];
|
|
38
40
|
excludeGlobs?: string[];
|
package/dist/config-manager.js
CHANGED
|
@@ -43,6 +43,7 @@ const PRESETS = {
|
|
|
43
43
|
balanced: {
|
|
44
44
|
autoScan: true,
|
|
45
45
|
sensitiveFilePolicy: 'ask',
|
|
46
|
+
semanticFilePolicy: 'hash_only',
|
|
46
47
|
maxFileSizeMb: 32,
|
|
47
48
|
notifyLevel: 'all',
|
|
48
49
|
excludeDirs: [],
|
|
@@ -54,6 +55,7 @@ const PRESETS = {
|
|
|
54
55
|
privacy_first: {
|
|
55
56
|
autoScan: true,
|
|
56
57
|
sensitiveFilePolicy: 'hash_only',
|
|
58
|
+
semanticFilePolicy: 'hash_only',
|
|
57
59
|
maxFileSizeMb: 32,
|
|
58
60
|
notifyLevel: 'threats_only',
|
|
59
61
|
excludeDirs: [],
|
|
@@ -65,6 +67,7 @@ const PRESETS = {
|
|
|
65
67
|
strict_security: {
|
|
66
68
|
autoScan: true,
|
|
67
69
|
sensitiveFilePolicy: 'always_upload',
|
|
70
|
+
semanticFilePolicy: 'ask',
|
|
68
71
|
maxFileSizeMb: 64,
|
|
69
72
|
notifyLevel: 'all',
|
|
70
73
|
excludeDirs: [],
|
|
@@ -79,6 +82,7 @@ const BALANCED_DEFAULTS = {
|
|
|
79
82
|
autoScan: true,
|
|
80
83
|
maxFileSizeMb: 32,
|
|
81
84
|
sensitiveFilePolicy: 'ask',
|
|
85
|
+
semanticFilePolicy: 'hash_only',
|
|
82
86
|
notifyLevel: 'all',
|
|
83
87
|
excludeDirs: [],
|
|
84
88
|
excludeGlobs: [],
|
|
@@ -141,6 +145,14 @@ function validateOverrides(input) {
|
|
|
141
145
|
errors.push(`Invalid sensitiveFilePolicy: "${input.sensitiveFilePolicy}". Must be: ask, ask_once, always_upload, hash_only`);
|
|
142
146
|
}
|
|
143
147
|
}
|
|
148
|
+
if ('semanticFilePolicy' in input) {
|
|
149
|
+
if (VALID_SENSITIVE_POLICIES.has(input.semanticFilePolicy)) {
|
|
150
|
+
valid.semanticFilePolicy = input.semanticFilePolicy;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
errors.push(`Invalid semanticFilePolicy: "${input.semanticFilePolicy}". Must be: ask, ask_once, always_upload, hash_only`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
144
156
|
if ('autoScan' in input) {
|
|
145
157
|
if (typeof input.autoScan === 'boolean') {
|
|
146
158
|
valid.autoScan = input.autoScan;
|
|
@@ -305,6 +317,8 @@ class ConfigManager {
|
|
|
305
317
|
result.maxFileSizeMb = s.maxFileSizeMb;
|
|
306
318
|
if (s.sensitiveFilePolicy !== undefined)
|
|
307
319
|
result.sensitiveFilePolicy = s.sensitiveFilePolicy;
|
|
320
|
+
if (s.semanticFilePolicy !== undefined)
|
|
321
|
+
result.semanticFilePolicy = s.semanticFilePolicy;
|
|
308
322
|
if (s.notifyLevel !== undefined)
|
|
309
323
|
result.notifyLevel = s.notifyLevel;
|
|
310
324
|
if (s.excludeDirs !== undefined)
|
|
@@ -337,6 +351,8 @@ class ConfigManager {
|
|
|
337
351
|
result.maxFileSizeMb = o.maxFileSizeMb;
|
|
338
352
|
if (o.sensitiveFilePolicy !== undefined)
|
|
339
353
|
result.sensitiveFilePolicy = o.sensitiveFilePolicy;
|
|
354
|
+
if (o.semanticFilePolicy !== undefined)
|
|
355
|
+
result.semanticFilePolicy = o.semanticFilePolicy;
|
|
340
356
|
if (o.notifyLevel !== undefined)
|
|
341
357
|
result.notifyLevel = o.notifyLevel;
|
|
342
358
|
if (o.excludeDirs !== undefined)
|
|
@@ -391,7 +407,7 @@ class ConfigManager {
|
|
|
391
407
|
const b = JSON.stringify(after[key]);
|
|
392
408
|
if (a !== b) {
|
|
393
409
|
changedFields.push(key);
|
|
394
|
-
if (key === 'sensitiveFilePolicy' || key === 'maxFileSizeMb') {
|
|
410
|
+
if (key === 'sensitiveFilePolicy' || key === 'semanticFilePolicy' || key === 'maxFileSizeMb') {
|
|
395
411
|
scannerNeedsRebuild = true;
|
|
396
412
|
}
|
|
397
413
|
if (key === 'watchDirs' || key === 'excludeDirs' || key === 'autoScan') {
|
package/dist/index.js
CHANGED
|
@@ -46,6 +46,7 @@ const fs = __importStar(require("fs"));
|
|
|
46
46
|
const os = __importStar(require("os"));
|
|
47
47
|
const path = __importStar(require("path"));
|
|
48
48
|
const scanner_1 = require("./scanner");
|
|
49
|
+
const classifier_1 = require("./classifier");
|
|
49
50
|
const path_extractor_1 = require("./path-extractor");
|
|
50
51
|
const vt_api_1 = require("./vt-api");
|
|
51
52
|
const audit_log_1 = require("./audit-log");
|
|
@@ -366,7 +367,7 @@ function vtSentinelPlugin(api) {
|
|
|
366
367
|
// User-provided key → standard VT API
|
|
367
368
|
if (!process.env.VIRUSTOTAL_API_KEY)
|
|
368
369
|
process.env.VIRUSTOTAL_API_KEY = userApiKey;
|
|
369
|
-
scanner = new scanner_1.Scanner(userApiKey, api.logger, eff.maxFileSizeMb, eff.sensitiveFilePolicy);
|
|
370
|
+
scanner = new scanner_1.Scanner(userApiKey, api.logger, eff.maxFileSizeMb, eff.sensitiveFilePolicy, false, eff.semanticFilePolicy);
|
|
370
371
|
api.logger.info('[VT-Sentinel] Using user-provided API key (standard VT API)');
|
|
371
372
|
}
|
|
372
373
|
else {
|
|
@@ -386,7 +387,7 @@ function vtSentinelPlugin(api) {
|
|
|
386
387
|
else {
|
|
387
388
|
api.logger.info(`[VT-Sentinel] Using cached VTAI agent: ${creds.publicHandle}`);
|
|
388
389
|
}
|
|
389
|
-
scanner = new scanner_1.Scanner(creds.agentToken, api.logger, eff.maxFileSizeMb, eff.sensitiveFilePolicy, true);
|
|
390
|
+
scanner = new scanner_1.Scanner(creds.agentToken, api.logger, eff.maxFileSizeMb, eff.sensitiveFilePolicy, true, eff.semanticFilePolicy);
|
|
390
391
|
if (!process.env.VIRUSTOTAL_API_KEY)
|
|
391
392
|
process.env.VIRUSTOTAL_API_KEY = 'vtai-active';
|
|
392
393
|
}
|
|
@@ -862,7 +863,7 @@ function vtSentinelPlugin(api) {
|
|
|
862
863
|
// --- Tool: vt_upload_consent ---
|
|
863
864
|
api.registerTool({
|
|
864
865
|
name: 'vt_upload_consent',
|
|
865
|
-
description: 'Confirm or deny uploading a
|
|
866
|
+
description: 'Confirm or deny uploading a file to VirusTotal after a needs_consent verdict. Call this after asking the user whether they want to upload a file that was flagged as sensitive (PDF, Office, unknown archive) or as an instruction file (SKILL.md, TOOLS.md, AGENTS.md, etc.).',
|
|
866
867
|
parameters: {
|
|
867
868
|
type: 'object',
|
|
868
869
|
properties: {
|
|
@@ -881,13 +882,16 @@ function vtSentinelPlugin(api) {
|
|
|
881
882
|
const s = await ensureScanner();
|
|
882
883
|
if (!s)
|
|
883
884
|
return textResponse('Error: VT-Sentinel not configured (API key missing and VTAI registration failed)');
|
|
885
|
+
// Determine consent group from file category
|
|
886
|
+
const fileCategory = classifier_1.FileClassifier.classify(params.path);
|
|
887
|
+
const consentGroup = fileCategory === classifier_1.FileCategory.SEMANTIC_RISK ? 'semantic' : 'sensitive';
|
|
884
888
|
if (!params.upload) {
|
|
885
|
-
s.recordConsent(false);
|
|
889
|
+
s.recordConsent(false, consentGroup);
|
|
886
890
|
return textResponse(`Upload declined. File hash was already checked (not found in VT). ` +
|
|
887
891
|
`The file was NOT uploaded — your privacy is preserved.`);
|
|
888
892
|
}
|
|
889
893
|
try {
|
|
890
|
-
s.recordConsent(true);
|
|
894
|
+
s.recordConsent(true, consentGroup);
|
|
891
895
|
const result = await s.uploadWithConsent(params.path);
|
|
892
896
|
auditResult(result);
|
|
893
897
|
return textResponse(formatResult(result));
|
|
@@ -902,6 +906,7 @@ function vtSentinelPlugin(api) {
|
|
|
902
906
|
if (diff.scannerNeedsRebuild && scanner) {
|
|
903
907
|
scanner.updateMaxFileSizeMb(newConfig.maxFileSizeMb);
|
|
904
908
|
scanner.updateSensitivePolicy(newConfig.sensitiveFilePolicy);
|
|
909
|
+
scanner.updateSemanticPolicy(newConfig.semanticFilePolicy);
|
|
905
910
|
api.logger.info('[VT-Sentinel] Scanner config updated');
|
|
906
911
|
}
|
|
907
912
|
if (diff.changedFields.includes('autoScan')) {
|
|
@@ -991,6 +996,7 @@ function vtSentinelPlugin(api) {
|
|
|
991
996
|
preset: { type: 'string', enum: ['balanced', 'privacy_first', 'strict_security'], description: 'Configuration preset' },
|
|
992
997
|
notifyLevel: { type: 'string', enum: ['all', 'threats_only', 'silent'], description: 'Notification verbosity' },
|
|
993
998
|
sensitiveFilePolicy: { type: 'string', enum: ['ask', 'ask_once', 'always_upload', 'hash_only'], description: 'Policy for sensitive files' },
|
|
999
|
+
semanticFilePolicy: { type: 'string', enum: ['ask', 'ask_once', 'always_upload', 'hash_only'], description: 'Policy for instruction files (SKILL.md, HOOK.md, TOOLS.md, AGENTS.md). Default: hash_only' },
|
|
994
1000
|
autoScan: { type: 'boolean', description: 'Enable/disable auto-scan' },
|
|
995
1001
|
maxFileSizeMb: { type: 'number', description: 'Max file size to scan (MB)' },
|
|
996
1002
|
watchDirsAdd: { type: 'array', items: { type: 'string' }, description: 'Directories to add to watch list' },
|
|
@@ -1103,6 +1109,7 @@ function vtSentinelPlugin(api) {
|
|
|
1103
1109
|
if (scanner) {
|
|
1104
1110
|
scanner.updateMaxFileSizeMb(newConfig.maxFileSizeMb);
|
|
1105
1111
|
scanner.updateSensitivePolicy(newConfig.sensitiveFilePolicy);
|
|
1112
|
+
scanner.updateSemanticPolicy(newConfig.semanticFilePolicy);
|
|
1106
1113
|
}
|
|
1107
1114
|
// Reconcile watcher state with restored config
|
|
1108
1115
|
if (newConfig.autoScan && !watcher) {
|
|
@@ -1255,7 +1262,7 @@ function vtSentinelPlugin(api) {
|
|
|
1255
1262
|
// Update scanner with new token
|
|
1256
1263
|
if (scanner) {
|
|
1257
1264
|
const scanEff = configManager.getEffective();
|
|
1258
|
-
scanner = new scanner_1.Scanner(newCreds.agentToken, api.logger, scanEff.maxFileSizeMb, scanEff.sensitiveFilePolicy, true);
|
|
1265
|
+
scanner = new scanner_1.Scanner(newCreds.agentToken, api.logger, scanEff.maxFileSizeMb, scanEff.sensitiveFilePolicy, true, scanEff.semanticFilePolicy);
|
|
1259
1266
|
}
|
|
1260
1267
|
const lines = ['Agent re-registered successfully:'];
|
|
1261
1268
|
lines.push(` New handle: ${newCreds.publicHandle}`);
|
|
@@ -1276,13 +1283,10 @@ function vtSentinelPlugin(api) {
|
|
|
1276
1283
|
});
|
|
1277
1284
|
// --- Hook: auto-scan tool results ---
|
|
1278
1285
|
const handleToolResult = async (event) => {
|
|
1279
|
-
|
|
1280
|
-
if (!s)
|
|
1281
|
-
return;
|
|
1282
|
-
// Enrich interesting dirs from context on first event
|
|
1286
|
+
// Enrich interesting dirs from context on first event (no scanner needed)
|
|
1283
1287
|
if (!contextEnriched)
|
|
1284
1288
|
enrichFromContext(event);
|
|
1285
|
-
// First-run onboarding: deliver once per workspace scope
|
|
1289
|
+
// First-run onboarding: deliver once per workspace scope (no scanner needed)
|
|
1286
1290
|
if (!firstRunDelivered) {
|
|
1287
1291
|
const scope = {
|
|
1288
1292
|
workspaceDir: resolvedWorkspaceDir,
|
|
@@ -1314,6 +1318,14 @@ function vtSentinelPlugin(api) {
|
|
|
1314
1318
|
firstRunDelivered = true;
|
|
1315
1319
|
}
|
|
1316
1320
|
}
|
|
1321
|
+
// autoScan=false disables hook scanning (active blocking in handleBeforeToolCall remains always-on)
|
|
1322
|
+
const hookEff = configManager.getEffective();
|
|
1323
|
+
if (!hookEff.autoScan)
|
|
1324
|
+
return;
|
|
1325
|
+
// Initialize scanner only when we're actually going to scan
|
|
1326
|
+
const s = await ensureScanner();
|
|
1327
|
+
if (!s)
|
|
1328
|
+
return;
|
|
1317
1329
|
const toolName = event.toolName || event.tool || '';
|
|
1318
1330
|
const toolParams = event.toolParams || event.params || event.input || {};
|
|
1319
1331
|
const toolResultText = extractResultText(event);
|
|
@@ -1323,13 +1335,25 @@ function vtSentinelPlugin(api) {
|
|
|
1323
1335
|
if (shouldLog('pending')) {
|
|
1324
1336
|
api.logger.info(`[VT-Sentinel] Auto-scan: ${targets.length} file(s) from ${toolName} tool`);
|
|
1325
1337
|
}
|
|
1326
|
-
const hookEff = configManager.getEffective();
|
|
1327
1338
|
for (const target of targets) {
|
|
1328
1339
|
if (isSelfPath(target.path))
|
|
1329
1340
|
continue;
|
|
1341
|
+
// excludeGlobs: skip files matching any exclude pattern
|
|
1342
|
+
if (hookEff.excludeGlobs.length > 0) {
|
|
1343
|
+
let excluded = false;
|
|
1344
|
+
for (const glob of hookEff.excludeGlobs) {
|
|
1345
|
+
if ((0, config_manager_1.matchGlob)(target.path, glob)) {
|
|
1346
|
+
excluded = true;
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
if (excluded)
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1330
1353
|
try {
|
|
1331
1354
|
let precomputedHash;
|
|
1332
|
-
|
|
1355
|
+
const isReadTarget = target.source === 'read_target';
|
|
1356
|
+
if (isReadTarget) {
|
|
1333
1357
|
try {
|
|
1334
1358
|
const currentHash = await (0, vt_api_1.calculateSHA256)(target.path);
|
|
1335
1359
|
const previousHash = readScanRegistry.get(target.path);
|
|
@@ -1344,7 +1368,7 @@ function vtSentinelPlugin(api) {
|
|
|
1344
1368
|
// If hash computation fails, proceed with scan anyway
|
|
1345
1369
|
}
|
|
1346
1370
|
}
|
|
1347
|
-
const result = await s.scanFile(target.path, false, precomputedHash);
|
|
1371
|
+
const result = await s.scanFile(target.path, false, precomputedHash, isReadTarget);
|
|
1348
1372
|
auditResult(result);
|
|
1349
1373
|
if (result.verdict === 'malicious') {
|
|
1350
1374
|
if (shouldLog('malicious')) {
|
|
@@ -1388,8 +1412,10 @@ function vtSentinelPlugin(api) {
|
|
|
1388
1412
|
api.logger.warn(`[VT-Sentinel] Unknown: ${result.fileName} — ${result.message}`);
|
|
1389
1413
|
}
|
|
1390
1414
|
// Update read-scan registry AFTER successful scan.
|
|
1391
|
-
//
|
|
1392
|
-
|
|
1415
|
+
// For hashOnly (read_target): 'unknown' means "hash not in VT" — stable, cache it.
|
|
1416
|
+
// For non-hashOnly: 'unknown' may be transient API error → allow retry on next read.
|
|
1417
|
+
const cacheableVerdict = result.verdict !== 'unknown' || isReadTarget;
|
|
1418
|
+
if (precomputedHash && cacheableVerdict) {
|
|
1393
1419
|
readScanRegistry.set(target.path, precomputedHash);
|
|
1394
1420
|
if (readScanRegistry.size > READ_SCAN_REGISTRY_MAX) {
|
|
1395
1421
|
const toEvict = Math.floor(READ_SCAN_REGISTRY_MAX / 2);
|
package/dist/scanner.d.ts
CHANGED
|
@@ -34,35 +34,42 @@ export declare class Scanner {
|
|
|
34
34
|
private limiter;
|
|
35
35
|
private maxFileSizeMb;
|
|
36
36
|
private sensitivePolicy;
|
|
37
|
-
|
|
37
|
+
private semanticPolicy;
|
|
38
|
+
/** In-memory consent cache for "ask_once" policy on sensitive files. null = not yet asked. */
|
|
38
39
|
private consentDecision;
|
|
40
|
+
/** In-memory consent cache for "ask_once" policy on semantic files. null = not yet asked. */
|
|
41
|
+
private consentDecisionSemantic;
|
|
39
42
|
private logger;
|
|
40
43
|
constructor(apiKey: string, logger: {
|
|
41
44
|
info: (m: string) => void;
|
|
42
45
|
warn: (m: string) => void;
|
|
43
46
|
error: (m: string) => void;
|
|
44
|
-
}, maxFileSizeMb?: number, sensitivePolicy?: SensitiveFilePolicy, useVtai?: boolean);
|
|
47
|
+
}, maxFileSizeMb?: number, sensitivePolicy?: SensitiveFilePolicy, useVtai?: boolean, semanticPolicy?: SensitiveFilePolicy);
|
|
45
48
|
/**
|
|
46
49
|
* Record the user's consent decision (used by the tool when the user responds).
|
|
47
50
|
* When policy is "ask_once", this persists for the session.
|
|
51
|
+
* @param consentGroup — 'sensitive' or 'semantic', determines which consent cache to update.
|
|
48
52
|
*/
|
|
49
|
-
recordConsent(upload: boolean): void;
|
|
53
|
+
recordConsent(upload: boolean, consentGroup?: 'sensitive' | 'semantic'): void;
|
|
50
54
|
/** Update maxFileSizeMb at runtime without rebuilding scanner. */
|
|
51
55
|
updateMaxFileSizeMb(mb: number): void;
|
|
52
56
|
/** Update sensitiveFilePolicy at runtime. Resets consent decision. */
|
|
53
57
|
updateSensitivePolicy(policy: SensitiveFilePolicy): void;
|
|
58
|
+
/** Update semanticFilePolicy at runtime. Resets semantic consent decision. */
|
|
59
|
+
updateSemanticPolicy(policy: SensitiveFilePolicy): void;
|
|
54
60
|
/**
|
|
55
61
|
* Full scan of a file: classify → hash → VT lookup → code insight if applicable.
|
|
56
62
|
* When force=true (manual scan), always do at least a hash check even for SAFE/MEDIA files.
|
|
63
|
+
* When hashOnly=true: only check hash against VT, never upload regardless of category.
|
|
57
64
|
*/
|
|
58
|
-
scanFile(filePath: string, force?: boolean, precomputedHash?: string): Promise<ScanResult>;
|
|
65
|
+
scanFile(filePath: string, force?: boolean, precomputedHash?: string, hashOnly?: boolean): Promise<ScanResult>;
|
|
59
66
|
/**
|
|
60
67
|
* Scan a HIGH_RISK or SEMANTIC_RISK file: hash lookup → upload if unknown.
|
|
61
68
|
* Code Insight is extracted automatically by fromReport() (same as all categories).
|
|
62
69
|
*/
|
|
63
70
|
private scanAutoUpload;
|
|
64
71
|
/**
|
|
65
|
-
* Scan a SENSITIVE file according to the
|
|
72
|
+
* Scan a SENSITIVE or SEMANTIC_RISK file according to the given policy.
|
|
66
73
|
*
|
|
67
74
|
* Step 1 (always): Check hash — this reveals nothing about file content.
|
|
68
75
|
* - If VT knows the hash → report findings (malicious PDF templates, etc.)
|
|
@@ -72,6 +79,8 @@ export declare class Scanner {
|
|
|
72
79
|
* - "always_upload" → upload to VT
|
|
73
80
|
* - "ask" → return needs_consent every time
|
|
74
81
|
* - "ask_once" → return needs_consent the first time, then use remembered decision
|
|
82
|
+
*
|
|
83
|
+
* @param consentGroup — 'sensitive' or 'semantic', determines which consent cache to use
|
|
75
84
|
*/
|
|
76
85
|
private scanSensitive;
|
|
77
86
|
/**
|
|
@@ -79,6 +88,11 @@ export declare class Scanner {
|
|
|
79
88
|
* Always checks hash; does NOT upload (no privacy concern but no reason to upload safe files).
|
|
80
89
|
*/
|
|
81
90
|
private scanForced;
|
|
91
|
+
/**
|
|
92
|
+
* Hash-only check: rate-limited hash lookup, never uploads.
|
|
93
|
+
* Used when hashOnly=true (e.g., files read by the agent).
|
|
94
|
+
*/
|
|
95
|
+
private hashCheckOnly;
|
|
82
96
|
private uploadSensitive;
|
|
83
97
|
private needsConsent;
|
|
84
98
|
/**
|
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
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
207
|
+
if (consent === true) {
|
|
178
208
|
return this.uploadSensitive(filePath, sha256, category, fileName);
|
|
179
209
|
}
|
|
180
|
-
if (
|
|
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.
|
|
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);
|
package/dist/status-renderer.js
CHANGED
|
@@ -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 |
|
|
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(' -
|
|
182
|
-
lines.push('
|
|
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(' -
|
|
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
|
}
|
|
@@ -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
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
|
|
13
|
-
let Scanner, extractPaths, loadAgentCredentials;
|
|
13
|
+
let Scanner, extractPaths, loadAgentCredentials, ConfigManager, matchGlob, isSelfPath, StateStore;
|
|
14
14
|
|
|
15
15
|
try {
|
|
16
16
|
// Try to load from the compiled plugin
|
|
@@ -18,21 +18,44 @@ try {
|
|
|
18
18
|
Scanner = require(path.join(distDir, 'scanner')).Scanner;
|
|
19
19
|
extractPaths = require(path.join(distDir, 'path-extractor')).extractPaths;
|
|
20
20
|
loadAgentCredentials = require(path.join(distDir, 'vt-api')).loadAgentCredentials;
|
|
21
|
+
ConfigManager = require(path.join(distDir, 'config-manager')).ConfigManager;
|
|
22
|
+
matchGlob = require(path.join(distDir, 'config-manager')).matchGlob;
|
|
23
|
+
isSelfPath = require(path.join(distDir, 'index')).isSelfPath;
|
|
24
|
+
StateStore = require(path.join(distDir, 'state-store')).StateStore;
|
|
21
25
|
} catch (e) {
|
|
22
26
|
// Modules not available — this hook won't function standalone
|
|
23
27
|
console.error('[vt-auto-scan] Could not load scanner modules:', e.message);
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Read static plugin config from OpenClaw config file.
|
|
32
|
+
* Returns null if not found or on error.
|
|
33
|
+
*/
|
|
34
|
+
function readStaticConfig() {
|
|
35
|
+
try {
|
|
36
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR || path.join(require('os').homedir(), '.openclaw');
|
|
37
|
+
const configPath = path.join(stateDir, 'openclaw.json');
|
|
38
|
+
if (!fs.existsSync(configPath)) return null;
|
|
39
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
40
|
+
const parsed = (() => {
|
|
41
|
+
try { return require('json5').parse(raw); } catch { return JSON.parse(raw); }
|
|
42
|
+
})();
|
|
43
|
+
return parsed?.plugins?.entries?.['openclaw-plugin-vt-sentinel']?.config || null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
/**
|
|
27
50
|
* Create a scanner instance using available credentials.
|
|
28
51
|
* Priority: user API key > cached VTAI agent token.
|
|
29
52
|
*/
|
|
30
|
-
function createScanner(logger) {
|
|
53
|
+
function createScanner(logger, eff) {
|
|
31
54
|
const apiKey = process.env.VIRUSTOTAL_API_KEY;
|
|
32
55
|
|
|
33
56
|
// User has their own VT API key (and it's not the 'active' placeholder)
|
|
34
57
|
if (apiKey && apiKey !== 'vtai-active') {
|
|
35
|
-
return new Scanner(apiKey, logger);
|
|
58
|
+
return new Scanner(apiKey, logger, eff.maxFileSizeMb, eff.sensitiveFilePolicy, false, eff.semanticFilePolicy);
|
|
36
59
|
}
|
|
37
60
|
|
|
38
61
|
// Try VTAI cached credentials
|
|
@@ -40,7 +63,7 @@ function createScanner(logger) {
|
|
|
40
63
|
try {
|
|
41
64
|
const creds = loadAgentCredentials();
|
|
42
65
|
if (creds) {
|
|
43
|
-
return new Scanner(creds.agentToken, logger,
|
|
66
|
+
return new Scanner(creds.agentToken, logger, eff.maxFileSizeMb, eff.sensitiveFilePolicy, true, eff.semanticFilePolicy);
|
|
44
67
|
}
|
|
45
68
|
} catch (e) {
|
|
46
69
|
// Credentials not available
|
|
@@ -56,13 +79,37 @@ const handler = async (event) => {
|
|
|
56
79
|
|
|
57
80
|
if (!Scanner || !extractPaths) return;
|
|
58
81
|
|
|
82
|
+
// Load config (static + persisted overrides) and check autoScan
|
|
83
|
+
let configManager = null;
|
|
84
|
+
if (ConfigManager) {
|
|
85
|
+
const staticConfig = readStaticConfig();
|
|
86
|
+
configManager = new ConfigManager(staticConfig);
|
|
87
|
+
// Apply persisted runtime overrides from vt-sentinel-state.json
|
|
88
|
+
if (StateStore) {
|
|
89
|
+
try {
|
|
90
|
+
const stateStore = new StateStore();
|
|
91
|
+
configManager.loadPersistedOverrides(stateStore.getPersistedOverrides());
|
|
92
|
+
} catch {
|
|
93
|
+
// State file missing or corrupt — proceed with static config only
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const eff = configManager ? configManager.getEffective() : {
|
|
98
|
+
autoScan: true, maxFileSizeMb: 32,
|
|
99
|
+
sensitiveFilePolicy: 'ask', semanticFilePolicy: 'hash_only',
|
|
100
|
+
excludeGlobs: [],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// autoScan=false disables hook scanning
|
|
104
|
+
if (!eff.autoScan) return;
|
|
105
|
+
|
|
59
106
|
const logger = {
|
|
60
107
|
info: (m) => console.log(m),
|
|
61
108
|
warn: (m) => console.warn(m),
|
|
62
109
|
error: (m) => console.error(m),
|
|
63
110
|
};
|
|
64
111
|
|
|
65
|
-
const scanner = createScanner(logger);
|
|
112
|
+
const scanner = createScanner(logger, eff);
|
|
66
113
|
if (!scanner) return;
|
|
67
114
|
|
|
68
115
|
const toolName = event.toolName || event.tool || '';
|
|
@@ -83,8 +130,22 @@ const handler = async (event) => {
|
|
|
83
130
|
if (targets.length === 0) return;
|
|
84
131
|
|
|
85
132
|
for (const target of targets) {
|
|
133
|
+
// Self-exclusion
|
|
134
|
+
if (isSelfPath && isSelfPath(target.path)) continue;
|
|
135
|
+
|
|
136
|
+
// excludeGlobs: skip files matching any exclude pattern
|
|
137
|
+
if (matchGlob && eff.excludeGlobs && eff.excludeGlobs.length > 0) {
|
|
138
|
+
let excluded = false;
|
|
139
|
+
for (const glob of eff.excludeGlobs) {
|
|
140
|
+
if (matchGlob(target.path, glob)) { excluded = true; break; }
|
|
141
|
+
}
|
|
142
|
+
if (excluded) continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
86
145
|
try {
|
|
87
|
-
|
|
146
|
+
// read_target → hash-only (never upload files the agent merely reads)
|
|
147
|
+
const isReadTarget = target.source === 'read_target';
|
|
148
|
+
const result = await scanner.scanFile(target.path, false, undefined, isReadTarget);
|
|
88
149
|
if (result.verdict === 'malicious' || result.verdict === 'suspicious') {
|
|
89
150
|
const warning = `\n\n⚠️ VT-SENTINEL SECURITY ALERT ⚠️\n` +
|
|
90
151
|
`File: ${result.fileName} | Verdict: ${result.verdict.toUpperCase()}\n` +
|
package/openclaw.plugin.json
CHANGED
|
@@ -30,6 +30,12 @@
|
|
|
30
30
|
"default": "ask",
|
|
31
31
|
"description": "Policy for sensitive files (PDF, Office, unknown archives): ask = prompt each time, ask_once = prompt once and remember, always_upload = auto-upload, hash_only = never upload"
|
|
32
32
|
},
|
|
33
|
+
"semanticFilePolicy": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"enum": ["ask", "ask_once", "always_upload", "hash_only"],
|
|
36
|
+
"default": "hash_only",
|
|
37
|
+
"description": "Policy for instruction files (SKILL.md, HOOK.md, TOOLS.md, AGENTS.md, etc.): ask = prompt each time, ask_once = prompt once, always_upload = auto-upload, hash_only = never upload (default, privacy-safe)"
|
|
38
|
+
},
|
|
33
39
|
"notifyLevel": {
|
|
34
40
|
"type": "string",
|
|
35
41
|
"enum": ["all", "threats_only", "silent"],
|
|
@@ -94,6 +100,7 @@
|
|
|
94
100
|
"autoScan": { "label": "Auto-scan new files" },
|
|
95
101
|
"maxFileSizeMb": { "label": "Max File Size (MB)" },
|
|
96
102
|
"sensitiveFilePolicy": { "label": "Sensitive File Policy" },
|
|
103
|
+
"semanticFilePolicy": { "label": "Instruction File Policy" },
|
|
97
104
|
"notifyLevel": { "label": "Notification Level" },
|
|
98
105
|
"excludeDirs": { "label": "Exclude Directories" },
|
|
99
106
|
"excludeGlobs": { "label": "Exclude File Patterns" },
|
package/package.json
CHANGED
|
@@ -90,11 +90,11 @@ Code Insight works on any file type VT can analyze — scripts, skills, binaries
|
|
|
90
90
|
|
|
91
91
|
Classification uses magic bytes and content analysis (never extensions alone):
|
|
92
92
|
- **HIGH_RISK**: Binaries (PE, ELF, Mach-O), scripts (shebang/content patterns), ZIPs with executables → auto-scanned
|
|
93
|
-
- **SEMANTIC_RISK**: SKILL.md, HOOK.md, AGENTS.md, SOUL.md, skill ZIPs →
|
|
94
|
-
- **SENSITIVE**: PDF, Office docs, unknown ZIPs → hash checked, upload needs consent
|
|
93
|
+
- **SEMANTIC_RISK**: SKILL.md, HOOK.md, TOOLS.md, AGENTS.md, SOUL.md, skill ZIPs → hash checked, upload needs consent (default: hash_only)
|
|
94
|
+
- **SENSITIVE**: PDF, Office docs, unknown ZIPs → hash checked, upload needs consent (default: ask)
|
|
95
95
|
- **MEDIA/SAFE**: Images, video, audio, plain text → skipped in auto-scan, checked in manual scan
|
|
96
96
|
|
|
97
|
-
## Consent Flow for Sensitive Files
|
|
97
|
+
## Consent Flow for Sensitive / Semantic Files
|
|
98
98
|
|
|
99
99
|
When `vt_scan_file` returns `NEEDS_CONSENT`:
|
|
100
100
|
|
|
@@ -103,6 +103,8 @@ When `vt_scan_file` returns `NEEDS_CONSENT`:
|
|
|
103
103
|
3. Ask: "Would you like me to upload this file for a full scan?"
|
|
104
104
|
4. Call `vt_upload_consent` with their answer.
|
|
105
105
|
|
|
106
|
+
**Note**: Files read by the agent (via the `read` tool) are only hash-checked, never auto-uploaded. This protects instruction files like TOOLS.md and AGENTS.md from being uploaded to VT during normal agent operations.
|
|
107
|
+
|
|
106
108
|
## Admin Tools
|
|
107
109
|
|
|
108
110
|
### `vt_sentinel_status` — Current Status
|
|
@@ -118,6 +120,7 @@ Update any setting immediately. Changes persist to disk by default.
|
|
|
118
120
|
```
|
|
119
121
|
vt_sentinel_configure { "preset": "privacy_first" }
|
|
120
122
|
vt_sentinel_configure { "sensitiveFilePolicy": "hash_only", "notifyLevel": "threats_only" }
|
|
123
|
+
vt_sentinel_configure { "semanticFilePolicy": "ask" }
|
|
121
124
|
vt_sentinel_configure { "watchDirsAdd": ["/extra/dir"], "excludeGlobs": ["*.log"] }
|
|
122
125
|
vt_sentinel_configure { "blockMode": "log_only", "persist": "session" }
|
|
123
126
|
```
|