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 CHANGED
@@ -21,7 +21,7 @@ openclaw gateway restart
21
21
  openclaw plugins list | grep vt-sentinel
22
22
  ```
23
23
 
24
- Should show 8 tools registered.
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
 
@@ -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[];
@@ -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 sensitive 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).',
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
- const s = await ensureScanner();
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
- if (target.source === 'read_target') {
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
- // Skip 'unknown' (may be transient VT error allow retry on next read).
1392
- if (precomputedHash && result.verdict !== 'unknown') {
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
- /** In-memory consent cache for "ask_once" policy. null = not yet asked. */
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 configured policy.
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
- 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);
@@ -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
  }
@@ -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, 32, 'ask', true);
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
- const result = await scanner.scanFile(target.path);
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` +
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-plugin-vt-sentinel",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "VirusTotal Sentinel for OpenClaw - Malware detection and AI-powered code analysis",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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 → auto-scanned
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
  ```