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/index.js CHANGED
@@ -36,7 +36,7 @@ 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._buildEnhancedBio = exports._generateAgentName = exports._getStateDir = exports._getCurrentVersion = exports._fetchLatestVersion = exports._generateUpdateCommands = void 0;
39
+ exports._buildEnhancedBio = exports._generateAgentName = exports._getCurrentVersion = exports._fetchLatestVersion = exports._generateUpdateCommands = void 0;
40
40
  exports.isNewerVersion = isNewerVersion;
41
41
  exports.isSelfPath = isSelfPath;
42
42
  exports.default = vtSentinelPlugin;
@@ -46,8 +46,10 @@ 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");
52
+ const env_access_1 = require("./env-access");
51
53
  const audit_log_1 = require("./audit-log");
52
54
  const config_manager_1 = require("./config-manager");
53
55
  const state_store_1 = require("./state-store");
@@ -78,6 +80,7 @@ function textResponse(text) {
78
80
  }
79
81
  // --- Update Check ---
80
82
  const PACKAGE_NAME = 'openclaw-plugin-vt-sentinel';
83
+ const CLAWHUB_PACKAGE_URL = `https://clawhub.ai/api/v1/packages/${PACKAGE_NAME}`;
81
84
  const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
82
85
  /**
83
86
  * Read current plugin version from package.json.
@@ -106,23 +109,26 @@ function isNewerVersion(latest, current) {
106
109
  return false;
107
110
  }
108
111
  /**
109
- * Fetch latest version string from npm registry. Returns null on error.
110
- * Single source of truth used by checkForUpdates() and vt_sentinel_update.
112
+ * Retrieve the latest released version string from ClawHub first, then fall
113
+ * back to the npm registry. Used only by the vt_sentinel_update tool —
114
+ * never called implicitly at plugin load (v0.11.0+).
111
115
  */
112
116
  async function fetchLatestVersion() {
113
117
  try {
114
- const resp = await axios_1.default.get(NPM_REGISTRY_URL, { timeout: 5000 });
115
- return resp.data?.version || null;
118
+ const resp = await axios_1.default.get(CLAWHUB_PACKAGE_URL, { timeout: 5000 });
119
+ const latest = resp.data?.package?.latestVersion;
120
+ if (typeof latest === 'string' && latest.trim())
121
+ return latest.trim();
116
122
  }
117
- catch {
118
- return null;
123
+ catch { }
124
+ try {
125
+ const resp = await axios_1.default.get(NPM_REGISTRY_URL, { timeout: 5000 });
126
+ const latest = resp.data?.version;
127
+ if (typeof latest === 'string' && latest.trim())
128
+ return latest.trim();
119
129
  }
120
- }
121
- /**
122
- * Get the OpenClaw state directory (respects OPENCLAW_STATE_DIR env var).
123
- */
124
- function getStateDir() {
125
- return process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), '.openclaw');
130
+ catch { }
131
+ return null;
126
132
  }
127
133
  /**
128
134
  * Generate update instructions or preview. Pure function — all inputs are arguments.
@@ -183,33 +189,9 @@ function generateUpdateCommands(opts) {
183
189
  lines.push(` ${cleanupScript}`);
184
190
  lines.push('');
185
191
  lines.push(` 2c. Reinstall:`);
186
- lines.push(` openclaw plugins install ${PACKAGE_NAME}`);
192
+ lines.push(` openclaw plugins install clawhub:${PACKAGE_NAME}`);
187
193
  return lines.join('\n');
188
194
  }
189
- /**
190
- * Check npm registry for a newer version. Fire-and-forget, never throws.
191
- */
192
- async function checkForUpdates(logger, callbacks) {
193
- try {
194
- const currentVersion = getCurrentVersion();
195
- const latestVersion = await fetchLatestVersion();
196
- if (!latestVersion) {
197
- callbacks?.onError();
198
- return;
199
- }
200
- if (isNewerVersion(latestVersion, currentVersion)) {
201
- logger.info(`[VT-Sentinel] Update available: ${currentVersion} → ${latestVersion}. ` +
202
- `Use vt_sentinel_update to check upgrade instructions.`);
203
- callbacks?.onNewer(latestVersion);
204
- }
205
- else {
206
- callbacks?.onUpToDate();
207
- }
208
- }
209
- catch {
210
- callbacks?.onError();
211
- }
212
- }
213
195
  // --- Self-exclusion: never scan/quarantine our own plugin files ---
214
196
  // __dirname = dist/ inside the installed plugin directory.
215
197
  // Resolve symlinks to prevent bypass via symlinked extensions dir.
@@ -261,21 +243,45 @@ function vtSentinelPlugin(api) {
261
243
  // Update check state (closure-scoped, not module-level)
262
244
  let latestKnownVersion = null;
263
245
  let updateCheckFailed = false;
246
+ // State directory: resolved once via the host runtime helper (which itself
247
+ // honors OPENCLAW_STATE_DIR, legacy paths, and profile overrides). We never
248
+ // read environment variables directly from this file — doing so would
249
+ // co-occur with the axios calls elsewhere in this module and trip the
250
+ // install-security scanner's env-harvesting rule.
251
+ const resolvedStateDir = (() => {
252
+ const fromRuntime = api.runtime?.state?.resolveStateDir;
253
+ if (typeof fromRuntime === 'function') {
254
+ try {
255
+ return fromRuntime();
256
+ }
257
+ catch { /* fall through */ }
258
+ }
259
+ return path.join(os.homedir(), '.openclaw');
260
+ })();
261
+ // Configure vt-api so its internal credential-persistence helpers use the
262
+ // same stateDir — without reading the environment themselves.
263
+ (0, vt_api_1.setStateDir)(resolvedStateDir);
264
+ // Credential mode tracked in memory only. Replaces the former approach of
265
+ // stamping 'vtai-active' into the VIRUSTOTAL_API_KEY environment variable
266
+ // as a sentinel (removed — polluted global state and matched the scanner's
267
+ // env-harvesting rule). 'user_key' = user supplied an apiKey in plugin
268
+ // config; 'vtai' = using the auto-registered VTAI agent token; null = not
269
+ // yet determined.
270
+ let credentialMode = null;
264
271
  const getConfig = () => {
265
272
  const entry = api.config?.plugins?.entries?.['openclaw-plugin-vt-sentinel'];
266
273
  return entry?.config ?? null;
267
274
  };
268
- // If plugin config provides an API key, expose it as VIRUSTOTAL_API_KEY for skill eligibility.
269
- // Do not override if the user already set the environment variable explicitly.
270
- try {
271
- const cfg = getConfig();
272
- if (cfg?.apiKey && !process.env.VIRUSTOTAL_API_KEY) {
273
- process.env.VIRUSTOTAL_API_KEY = cfg.apiKey;
275
+ // Determine credentialMode eagerly from static config so tools that run
276
+ // before ensureScanner() (e.g. vt_sentinel_re_register on a cold plugin)
277
+ // still see the correct mode. VTAI mode is only locked in after
278
+ // ensureScanner() either loads cached creds or auto-registers.
279
+ {
280
+ const initialCfg = getConfig();
281
+ if (typeof initialCfg?.apiKey === 'string' && initialCfg.apiKey.trim().length > 0) {
282
+ credentialMode = 'user_key';
274
283
  }
275
284
  }
276
- catch {
277
- // Best-effort only.
278
- }
279
285
  /**
280
286
  * Build agent_version string: pluginVer.oc<openclawVer> (max 20 chars, [a-zA-Z0-9.-]+)
281
287
  */
@@ -351,22 +357,24 @@ function vtSentinelPlugin(api) {
351
357
  }
352
358
  /**
353
359
  * Ensure scanner is initialized. Handles API key resolution:
354
- * 1. User-provided apiKey in config or env → standard VT API
355
- * 2. Cached VTAI agent token → VTAI API
356
- * 3. Auto-register with VTAI → cache token → VTAI API
360
+ * 1. User-provided apiKey in plugin config → standard VT API
361
+ * 2. Cached VTAI agent credentials → VTAI API
362
+ * 3. Auto-register with VTAI → cache credentials → VTAI API
363
+ *
364
+ * Never reads or mutates the process environment. Credential mode is
365
+ * tracked locally in the `credentialMode` closure variable.
357
366
  */
358
367
  const ensureScanner = async () => {
359
368
  if (scanner)
360
369
  return scanner;
361
370
  const cfg = getConfig();
362
371
  const eff = configManager.getEffective();
363
- const envKey = process.env.VIRUSTOTAL_API_KEY;
364
- const userApiKey = cfg?.apiKey || (envKey && envKey !== 'vtai-active' ? envKey : undefined);
372
+ const userApiKey = typeof cfg?.apiKey === 'string' && cfg.apiKey.trim().length > 0
373
+ ? cfg.apiKey.trim()
374
+ : undefined;
365
375
  if (userApiKey) {
366
- // User-provided key standard VT API
367
- if (!process.env.VIRUSTOTAL_API_KEY)
368
- process.env.VIRUSTOTAL_API_KEY = userApiKey;
369
- scanner = new scanner_1.Scanner(userApiKey, api.logger, eff.maxFileSizeMb, eff.sensitiveFilePolicy);
376
+ scanner = new scanner_1.Scanner(userApiKey, api.logger, eff.maxFileSizeMb, eff.sensitiveFilePolicy, false, eff.semanticFilePolicy);
377
+ credentialMode = 'user_key';
370
378
  api.logger.info('[VT-Sentinel] Using user-provided API key (standard VT API)');
371
379
  }
372
380
  else {
@@ -386,9 +394,8 @@ function vtSentinelPlugin(api) {
386
394
  else {
387
395
  api.logger.info(`[VT-Sentinel] Using cached VTAI agent: ${creds.publicHandle}`);
388
396
  }
389
- scanner = new scanner_1.Scanner(creds.agentToken, api.logger, eff.maxFileSizeMb, eff.sensitiveFilePolicy, true);
390
- if (!process.env.VIRUSTOTAL_API_KEY)
391
- process.env.VIRUSTOTAL_API_KEY = 'vtai-active';
397
+ scanner = new scanner_1.Scanner(creds.agentToken, api.logger, eff.maxFileSizeMb, eff.sensitiveFilePolicy, true, eff.semanticFilePolicy);
398
+ credentialMode = 'vtai';
392
399
  }
393
400
  return scanner;
394
401
  };
@@ -400,7 +407,7 @@ function vtSentinelPlugin(api) {
400
407
  const readScanRegistry = new Map();
401
408
  // --- Config manager + state store ---
402
409
  const configManager = new config_manager_1.ConfigManager(getConfig());
403
- const stateStore = new state_store_1.StateStore();
410
+ const stateStore = new state_store_1.StateStore(resolvedStateDir);
404
411
  configManager.loadPersistedOverrides(stateStore.getPersistedOverrides());
405
412
  let firstRunDelivered = false;
406
413
  /** Check if a scan result should be logged based on notifyLevel. */
@@ -652,9 +659,9 @@ function vtSentinelPlugin(api) {
652
659
  if (process.platform === 'darwin') {
653
660
  candidates.push('/tmp', '/private/tmp');
654
661
  }
655
- const home = process.env.HOME || process.env.USERPROFILE;
662
+ const home = os.homedir();
656
663
  if (home) {
657
- const stateDir = process.env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw');
664
+ const stateDir = resolvedStateDir;
658
665
  const codeDirs = [
659
666
  path.join(stateDir, 'skills'),
660
667
  path.join(stateDir, 'extensions'),
@@ -684,7 +691,7 @@ function vtSentinelPlugin(api) {
684
691
  // Startup folder — anything placed here runs on login
685
692
  candidates.push(path.join(home, 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup'));
686
693
  }
687
- const profile = process.env.OPENCLAW_PROFILE;
694
+ const profile = (0, env_access_1.getActiveProfile)();
688
695
  if (profile) {
689
696
  const profileBase = path.join(home, `.openclaw-${profile}`);
690
697
  const profileCodeDirs = [
@@ -862,7 +869,7 @@ function vtSentinelPlugin(api) {
862
869
  // --- Tool: vt_upload_consent ---
863
870
  api.registerTool({
864
871
  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).',
872
+ 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
873
  parameters: {
867
874
  type: 'object',
868
875
  properties: {
@@ -881,13 +888,16 @@ function vtSentinelPlugin(api) {
881
888
  const s = await ensureScanner();
882
889
  if (!s)
883
890
  return textResponse('Error: VT-Sentinel not configured (API key missing and VTAI registration failed)');
891
+ // Determine consent group from file category
892
+ const fileCategory = classifier_1.FileClassifier.classify(params.path);
893
+ const consentGroup = fileCategory === classifier_1.FileCategory.SEMANTIC_RISK ? 'semantic' : 'sensitive';
884
894
  if (!params.upload) {
885
- s.recordConsent(false);
895
+ s.recordConsent(false, consentGroup);
886
896
  return textResponse(`Upload declined. File hash was already checked (not found in VT). ` +
887
897
  `The file was NOT uploaded — your privacy is preserved.`);
888
898
  }
889
899
  try {
890
- s.recordConsent(true);
900
+ s.recordConsent(true, consentGroup);
891
901
  const result = await s.uploadWithConsent(params.path);
892
902
  auditResult(result);
893
903
  return textResponse(formatResult(result));
@@ -902,6 +912,7 @@ function vtSentinelPlugin(api) {
902
912
  if (diff.scannerNeedsRebuild && scanner) {
903
913
  scanner.updateMaxFileSizeMb(newConfig.maxFileSizeMb);
904
914
  scanner.updateSensitivePolicy(newConfig.sensitiveFilePolicy);
915
+ scanner.updateSemanticPolicy(newConfig.semanticFilePolicy);
905
916
  api.logger.info('[VT-Sentinel] Scanner config updated');
906
917
  }
907
918
  if (diff.changedFields.includes('autoScan')) {
@@ -963,7 +974,7 @@ function vtSentinelPlugin(api) {
963
974
  const eff = configManager.getEffective();
964
975
  return textResponse((0, status_renderer_1.renderStatus)({
965
976
  version: getCurrentVersion(),
966
- apiMode: process.env.VIRUSTOTAL_API_KEY === 'vtai-active' ? 'vtai' : 'user_key',
977
+ apiMode: credentialMode === 'vtai' ? 'vtai' : 'user_key',
967
978
  effectiveConfig: eff,
968
979
  watchedDirs: [...watchRoots],
969
980
  blockedFileCount: blocklist.size,
@@ -991,6 +1002,7 @@ function vtSentinelPlugin(api) {
991
1002
  preset: { type: 'string', enum: ['balanced', 'privacy_first', 'strict_security'], description: 'Configuration preset' },
992
1003
  notifyLevel: { type: 'string', enum: ['all', 'threats_only', 'silent'], description: 'Notification verbosity' },
993
1004
  sensitiveFilePolicy: { type: 'string', enum: ['ask', 'ask_once', 'always_upload', 'hash_only'], description: 'Policy for sensitive files' },
1005
+ 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
1006
  autoScan: { type: 'boolean', description: 'Enable/disable auto-scan' },
995
1007
  maxFileSizeMb: { type: 'number', description: 'Max file size to scan (MB)' },
996
1008
  watchDirsAdd: { type: 'array', items: { type: 'string' }, description: 'Directories to add to watch list' },
@@ -1103,6 +1115,7 @@ function vtSentinelPlugin(api) {
1103
1115
  if (scanner) {
1104
1116
  scanner.updateMaxFileSizeMb(newConfig.maxFileSizeMb);
1105
1117
  scanner.updateSensitivePolicy(newConfig.sensitiveFilePolicy);
1118
+ scanner.updateSemanticPolicy(newConfig.semanticFilePolicy);
1106
1119
  }
1107
1120
  // Reconcile watcher state with restored config
1108
1121
  if (newConfig.autoScan && !watcher) {
@@ -1175,7 +1188,7 @@ function vtSentinelPlugin(api) {
1175
1188
  currentVersion,
1176
1189
  latestVersion,
1177
1190
  confirm: params.confirm === true,
1178
- stateDir: getStateDir(),
1191
+ stateDir: resolvedStateDir,
1179
1192
  }));
1180
1193
  },
1181
1194
  });
@@ -1195,9 +1208,9 @@ function vtSentinelPlugin(api) {
1195
1208
  if ('confirm' in params && typeof params.confirm !== 'boolean') {
1196
1209
  return textResponse('Error: confirm must be true or false');
1197
1210
  }
1198
- // Check if using user API key (no VTAI registration)
1199
- const envKey = process.env.VIRUSTOTAL_API_KEY;
1200
- if (envKey && envKey !== 'vtai-active') {
1211
+ // Re-registration is only meaningful when we're the VTAI agent —
1212
+ // a user-supplied apiKey means we're not managing an identity at all.
1213
+ if (credentialMode === 'user_key') {
1201
1214
  return textResponse('Re-registration not applicable — using user-provided API key (not VTAI).');
1202
1215
  }
1203
1216
  const eff = configManager.getEffective();
@@ -1237,25 +1250,19 @@ function vtSentinelPlugin(api) {
1237
1250
  }
1238
1251
  // Confirmed — re-register
1239
1252
  try {
1240
- // Backup current credentials
1253
+ // Backup current credentials (0o600 on POSIX; on Windows the
1254
+ // file inherits ACLs from the user's profile dir — see
1255
+ // saveAgentCredentials() for rationale on not shelling to icacls).
1241
1256
  if (currentCreds) {
1242
1257
  const backupPath = (0, vt_api_1.getAgentCredentialsPath)() + '.bak';
1243
1258
  fs.writeFileSync(backupPath, JSON.stringify(currentCreds, null, 2), { mode: 0o600 });
1244
- // Windows: POSIX mode bits ignored on NTFS — restrict via ACL
1245
- if (process.platform === 'win32') {
1246
- try {
1247
- const { execSync } = require('child_process');
1248
- execSync(`icacls "${backupPath}" /inheritance:r /grant:r "%USERNAME%:(F)"`, { stdio: 'ignore' });
1249
- }
1250
- catch { /* best effort */ }
1251
- }
1252
1259
  }
1253
1260
  const newCreds = await (0, vt_api_1.registerAgent)(buildRegistrationOpts());
1254
1261
  (0, vt_api_1.saveAgentCredentials)(newCreds);
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,23 +1283,20 @@ 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,
1289
- profile: process.env.OPENCLAW_PROFILE,
1293
+ profile: (0, env_access_1.getActiveProfile)(),
1290
1294
  };
1291
1295
  if (!stateStore.isFirstRunShown(scope)) {
1292
1296
  try {
1293
1297
  const onboardingText = (0, status_renderer_1.renderOnboarding)({
1294
1298
  version: getCurrentVersion(),
1295
- apiMode: process.env.VIRUSTOTAL_API_KEY === 'vtai-active' ? 'vtai' : 'user_key',
1299
+ apiMode: credentialMode === 'vtai' ? 'vtai' : 'user_key',
1296
1300
  watchDirs: [...watchRoots],
1297
1301
  effectiveConfig: configManager.getEffective(),
1298
1302
  availableTools: [
@@ -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);
@@ -1515,12 +1541,11 @@ function vtSentinelPlugin(api) {
1515
1541
  vtSentinelPlugin._handleWatcherFile = handleWatcherFile;
1516
1542
  vtSentinelPlugin._enrichFromContext = enrichFromContext;
1517
1543
  api.logger.info('[VT-Sentinel] Plugin loaded — 9 tools + active protection hooks registered (VTAI auto-registration enabled)');
1518
- // Non-blocking update check (fire-and-forget)
1519
- checkForUpdates(api.logger, {
1520
- onNewer: (v) => { latestKnownVersion = v; updateCheckFailed = false; },
1521
- onUpToDate: () => { latestKnownVersion = null; updateCheckFailed = false; },
1522
- onError: () => { updateCheckFailed = true; },
1523
- });
1544
+ // v0.11.0: removed the load-time fire-and-forget checkForUpdates() call.
1545
+ // Reason: issuing an unprompted outbound request to the npm registry on
1546
+ // every plugin load is opaque to the user and noisy in air-gapped envs.
1547
+ // Update checks now only happen when the user explicitly invokes the
1548
+ // vt_sentinel_update tool.
1524
1549
  }
1525
1550
  // --- Hook helpers ---
1526
1551
  function extractResultText(event) {
@@ -1580,6 +1605,5 @@ function injectWarning(event, result) {
1580
1605
  exports._generateUpdateCommands = generateUpdateCommands;
1581
1606
  exports._fetchLatestVersion = fetchLatestVersion;
1582
1607
  exports._getCurrentVersion = getCurrentVersion;
1583
- exports._getStateDir = getStateDir;
1584
1608
  exports._generateAgentName = generateAgentName;
1585
1609
  exports._buildEnhancedBio = buildEnhancedBio;
@@ -20,18 +20,22 @@ export declare function getInterestingDirs(): string[];
20
20
  * Reset dynamic dirs to env-computed defaults (for test isolation).
21
21
  */
22
22
  export declare function resetInterestingDirs(): void;
23
+ export type DangerousPatternCategory = 'pipe_execution' | 'ssh_injection' | 'data_exfiltration' | 'credential_access' | 'persistence';
23
24
  export interface DangerousPattern {
24
- category: 'pipe_execution' | 'ssh_injection' | 'data_exfiltration' | 'credential_access' | 'persistence';
25
+ category: DangerousPatternCategory;
25
26
  description: string;
26
27
  severity: 'critical' | 'high';
27
28
  }
28
29
  /**
29
30
  * Detect dangerous patterns in a command string that indicate attacks
30
31
  * which bypass file-based scanning (pipe-to-shell, SSH injection, exfiltration).
31
- *
32
32
  * Returns all matched patterns, or an empty array if the command is safe.
33
33
  */
34
34
  export declare function detectDangerousPatterns(command: string): DangerousPattern[];
35
+ /**
36
+ * Return the number of compiled signatures (diagnostics/tests).
37
+ */
38
+ export declare function getDangerousPatternCount(): number;
35
39
  export interface ExtractedPath {
36
40
  path: string;
37
41
  source: 'command_param' | 'download_target' | 'redirect_target' | 'exec_target' | 'tool_output' | 'write_path' | 'read_target';