openclaw-plugin-vt-sentinel 0.11.3 → 0.12.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/CHANGELOG.md CHANGED
@@ -2,6 +2,86 @@
2
2
 
3
3
  All notable changes to `openclaw-plugin-vt-sentinel`.
4
4
 
5
+ ## 0.12.0 — Transparency surface + log hygiene
6
+
7
+ **Headline:** what VT Sentinel does at runtime is now auditable from two
8
+ places: `vt_sentinel_status` and `openclaw security audit --deep --json`.
9
+ Both views are rendered from the same snapshot function, so they can't drift.
10
+
11
+ ### Added
12
+
13
+ - **`registerSecurityAuditCollector`** (new). Registers a plugin-scoped
14
+ collector that OpenClaw surfaces under `openclaw security audit --deep`.
15
+ Emits `info`-level findings for credential mode, endpoints, effective
16
+ policies, state/log paths, and identity metadata; emits `warn`-level
17
+ findings for risky config: `always_upload` on sensitive or semantic
18
+ files, broad watch dirs (root-level or whole-profile), audit files not
19
+ owner-private, `agentContactEmail` set (PII flagged as user's choice),
20
+ and the inconsistent `autoScan=false` + `blockMode=quarantine` posture.
21
+ - **`buildComplianceSnapshot`** — pure function in
22
+ `src/compliance-snapshot.ts`. Single source of truth consumed by the
23
+ security audit collector, the `vt_sentinel_status` tool output, and
24
+ tests. Uses `ctx.stateDir` / `ctx.configPath` — no global reads.
25
+ - **`vt_sentinel_status` Compliance / Data Flow block.** Renders live
26
+ endpoints, credential mode, state and log file paths, VTAI identity
27
+ metadata shape (never the values), and any active risk flags.
28
+ - **README Privacy & compliance section** with a data-flow table (what's
29
+ read / uploaded / where credentials live / how to opt out) and the
30
+ explicit note that `VIRUSTOTAL_API_KEY` was retired in 0.11.x.
31
+
32
+ ### Changed
33
+
34
+ - **Audit-log hygiene.** Log files (`uploads.log`, `detections.log`) are
35
+ pre-created at mode `0o600` so the first append cannot race the process
36
+ umask. The audit directory is created at `0o700`. Rotations rewrite
37
+ with an explicit owner-only mode and tighten again via `chmodSync`
38
+ on platforms where `writeFileSync` resets the mode.
39
+ - **Audit logs moved to `<stateDir>/vt-sentinel-audit/`.** Previously
40
+ `<stateDir>/vt-sentinel-uploads.log` and `...-detections.log`. Clean
41
+ move because production users had not yet produced entries (no
42
+ migration needed for most installs).
43
+ - **Empty file path is now recorded as `<in-memory>`.** Detections that
44
+ surface a SHA-256 without an on-disk path (extracted archive members,
45
+ EICAR-from-buffer scans) no longer emit an ambiguous trailing tab.
46
+ - **`getLogDir(stateDir)` takes an explicit argument.** The former
47
+ `process.env.OPENCLAW_STATE_DIR` fallback was removed for module
48
+ uniformity with the v0.11.x env-free stance.
49
+ - **Standalone `hooks/vt-auto-scan/` retired.** OpenClaw scans
50
+ `hooks/*/HOOK.md` for hook-pack discovery, which created a duplicate
51
+ registration alongside the runtime `api.registerHook(...)` calls in
52
+ `index.ts`. With `install.minHostVersion >=2026.3.22` guaranteed, the
53
+ runtime path is sufficient. One authoritative source, half the
54
+ maintenance surface.
55
+
56
+ ### Internal
57
+
58
+ - `src/update-commands.ts` — split from `index.ts` in 0.11.3 for scanner
59
+ hygiene; now also used by the compliance snapshot tests as an example
60
+ of the "pure helper" pattern we apply to new modules.
61
+
62
+ ### Deferred
63
+
64
+ - **Migration to `definePluginEntry`.** Verified empirically on the Linux
65
+ production VM (OpenClaw 2026.4.12): `require('openclaw/plugin-sdk/core')`
66
+ does not resolve from `~/.openclaw/extensions/<plugin>/dist/`. Would
67
+ require a peerDependency + NODE_PATH surgery with only cosmetic benefit.
68
+ The plain default-export plugin shape stays. Re-evaluate when OpenClaw
69
+ ships a formal plugin-sdk resolution helper.
70
+ - **SecretRef for `apiKey`.** Still blocked upstream —
71
+ `validatePluginConfig` in OpenClaw 2026.4.12 does not auto-resolve
72
+ SecretRefs for non-channel plugins.
73
+ - **`potential-exfiltration` in `dist/vt-api.js`.** The same finding
74
+ remains from 0.11.3: credential persistence used to live next to axios
75
+ calls. Since v0.11.3 split them into `vt-credentials.ts`, static scan
76
+ now reports clean. No action needed in 0.12.0.
77
+
78
+ ### Compatibility
79
+
80
+ Runtime-compatible with 0.11.x users. Existing credentials, runtime
81
+ overrides, and cached agent identities carry across the upgrade. The
82
+ two log-file paths are new; legacy files at the old stateDir root remain
83
+ as unused orphans and can be deleted manually if desired.
84
+
5
85
  ## 0.11.3 — ClawHub static-scan: eliminate last warn
6
86
 
7
87
  Runtime behavior identical to 0.11.2. One last structural change so ClawHub's
package/README.md CHANGED
@@ -122,7 +122,37 @@ File analysis includes:
122
122
  ## Privacy & compliance
123
123
 
124
124
  VT Sentinel is a security plugin, so transparency about what it reads, writes,
125
- and sends is part of the threat model. Highlights as of v0.11.0:
125
+ and sends is part of the threat model. The same structured view is emitted by
126
+ `vt_sentinel_status` (Compliance / Data Flow block) and by `openclaw security
127
+ audit --deep` (via the plugin's `securityAuditCollector` — since v0.12.0), so
128
+ you can verify the behavior from either surface without reading source.
129
+
130
+ ### Data flow
131
+
132
+ | Category | Detail |
133
+ |---|---|
134
+ | **Files read** | Candidate files under configured watch dirs — for hashing and classification. Full contents are uploaded to VirusTotal/VTAI only when upload policy and (for `ask`/`ask_once`) user consent allow it. Instruction files (`SKILL.md`, `HOOK.md`, `AGENTS.md`, etc.) default to `hash_only` and are never auto-uploaded. |
135
+ | **Files uploaded** | Hash lookups are free (no content sent). Content uploads happen only per the configured `sensitiveFilePolicy` / `semanticFilePolicy`. |
136
+ | **Network endpoints** | User-key mode: `www.virustotal.com`. VTAI mode: `ai.virustotal.com`. `registry.npmjs.org` and `clawhub.ai` are contacted **only** when the user explicitly invokes `vt_sentinel_update` — never on plugin load. |
137
+ | **Credentials stored** | `<stateDir>/vt-sentinel-agent.json` (mode `0o600`, owner-only). v0.12.0+ also enforces `0o600` on audit logs and `0o700` on the audit directory. |
138
+ | **Audit logs** | `<stateDir>/vt-sentinel-audit/uploads.log` and `detections.log`. Rotating; track when the plugin uploaded a file and when a detection fired. |
139
+ | **Runtime state** | `<stateDir>/vt-sentinel-state.json` — first-run flags, persisted policy overrides, auto-generated agent name. No sample file contents. |
140
+ | **Opt-outs** | `vt_sentinel_configure` → switch to `configPreset: privacy_first`, set `autoScan: false`, or switch per-category policy to `hash_only`. |
141
+
142
+ ### `VIRUSTOTAL_API_KEY` shell variable is retired
143
+
144
+ Earlier versions fell back to reading `VIRUSTOTAL_API_KEY` from the shell
145
+ environment. **That fallback was removed in 0.11.0.** If you previously
146
+ exported the variable, move the value into the plugin config once with:
147
+
148
+ ```
149
+ openclaw config set plugins.entries.openclaw-plugin-vt-sentinel.config.apiKey "vt_xxxxxxxx"
150
+ ```
151
+
152
+ or do nothing and let VTAI auto-register on first scan. Both are fully
153
+ supported; the env variable is not.
154
+
155
+ ### Legacy highlights retained from v0.11.0
126
156
 
127
157
  - **Network endpoints:** only `www.virustotal.com` (VT API) and
128
158
  `ai.virustotal.com` (VTAI). `registry.npmjs.org` / `clawhub.com` are
@@ -1,7 +1,18 @@
1
1
  /**
2
2
  * Simple rotating audit log.
3
- * Format: ISO-8601 timestamp \t SHA-256 \t full file path
4
- * Rotates by keeping the newest half when maxLines or maxBytes is exceeded.
3
+ *
4
+ * Entry format (v0.12.0+): `{ISO-8601}\t{SHA-256}\t{filePath or <sentinel>}\n`.
5
+ * Empty filePath (e.g. EICAR detection from a memory buffer or an extracted
6
+ * archive member whose original path was not surfaced) is recorded as
7
+ * `<in-memory>` so the log stays self-describing.
8
+ *
9
+ * Permissions hardening (v0.12.0+):
10
+ * - Parent directory is created with mode 0o700 if missing.
11
+ * - Log file is pre-created with mode 0o600 before the first append so the
12
+ * owner-only posture doesn't depend on umask.
13
+ * - Rotations rewrite the file with an explicit mode 0o600.
14
+ *
15
+ * Rotation: when maxLines or maxBytes is exceeded the newest half is kept.
5
16
  */
6
17
  export declare class AuditLog {
7
18
  private logPath;
@@ -9,11 +20,27 @@ export declare class AuditLog {
9
20
  private maxBytes;
10
21
  private lineCount;
11
22
  private approxSize;
23
+ private ensuredPermissions;
12
24
  constructor(logPath: string, maxLines?: number, maxBytes?: number);
25
+ /** Ensure parent dir exists at 0o700 and the log file exists at 0o600. */
26
+ private ensureContainerAndFile;
27
+ private seedCountersFromDisk;
28
+ /**
29
+ * Append a record. `filePath` may be empty — in that case we record the
30
+ * `<in-memory>` sentinel so the log doesn't emit an ambiguous trailing tab.
31
+ */
13
32
  append(sha256: string, filePath: string): void;
14
33
  private rotate;
15
34
  }
16
35
  /**
17
- * Returns the directory for VT Sentinel logs (same as state dir).
36
+ * Returns the directory for VT Sentinel audit logs.
37
+ *
38
+ * v0.12.0 change: logs now live in a dedicated `<stateDir>/vt-sentinel-audit/`
39
+ * subdirectory (pre-0.12 they were at the stateDir root). The subdir is
40
+ * created at 0o700 on first write.
41
+ *
42
+ * The stateDir must be supplied by the caller (previously fell back to reading
43
+ * the environment; that read has been removed to keep this module free of
44
+ * `process.env` lookups that could co-occur with HTTP clients elsewhere).
18
45
  */
19
- export declare function getLogDir(): string;
46
+ export declare function getLogDir(stateDir: string): string;
package/dist/audit-log.js CHANGED
@@ -37,31 +37,70 @@ exports.AuditLog = void 0;
37
37
  exports.getLogDir = getLogDir;
38
38
  const fs = __importStar(require("fs"));
39
39
  const path = __importStar(require("path"));
40
- const os = __importStar(require("os"));
41
40
  /**
42
41
  * Simple rotating audit log.
43
- * Format: ISO-8601 timestamp \t SHA-256 \t full file path
44
- * Rotates by keeping the newest half when maxLines or maxBytes is exceeded.
42
+ *
43
+ * Entry format (v0.12.0+): `{ISO-8601}\t{SHA-256}\t{filePath or <sentinel>}\n`.
44
+ * Empty filePath (e.g. EICAR detection from a memory buffer or an extracted
45
+ * archive member whose original path was not surfaced) is recorded as
46
+ * `<in-memory>` so the log stays self-describing.
47
+ *
48
+ * Permissions hardening (v0.12.0+):
49
+ * - Parent directory is created with mode 0o700 if missing.
50
+ * - Log file is pre-created with mode 0o600 before the first append so the
51
+ * owner-only posture doesn't depend on umask.
52
+ * - Rotations rewrite the file with an explicit mode 0o600.
53
+ *
54
+ * Rotation: when maxLines or maxBytes is exceeded the newest half is kept.
45
55
  */
46
56
  class AuditLog {
47
57
  constructor(logPath, maxLines = 1000, maxBytes = 1024 * 1024) {
48
58
  this.lineCount = 0;
49
59
  this.approxSize = 0;
60
+ this.ensuredPermissions = false;
50
61
  this.logPath = logPath;
51
62
  this.maxLines = maxLines;
52
63
  this.maxBytes = maxBytes;
53
- // Ensure parent directory exists
64
+ this.ensureContainerAndFile();
65
+ this.seedCountersFromDisk();
66
+ }
67
+ /** Ensure parent dir exists at 0o700 and the log file exists at 0o600. */
68
+ ensureContainerAndFile() {
54
69
  try {
55
- const dir = path.dirname(logPath);
56
- if (!fs.existsSync(dir))
57
- fs.mkdirSync(dir, { recursive: true });
70
+ const dir = path.dirname(this.logPath);
71
+ if (!fs.existsSync(dir)) {
72
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
73
+ }
74
+ else {
75
+ // Tighten permissions on the dir if it pre-existed wide-open (best-effort).
76
+ try {
77
+ fs.chmodSync(dir, 0o700);
78
+ }
79
+ catch { /* not a blocker */ }
80
+ }
81
+ if (!fs.existsSync(this.logPath)) {
82
+ // Pre-create empty so the first append doesn't race default umask.
83
+ fs.writeFileSync(this.logPath, '', { mode: 0o600 });
84
+ }
85
+ else {
86
+ // Tighten permissions on the file if it pre-existed wide-open.
87
+ try {
88
+ fs.chmodSync(this.logPath, 0o600);
89
+ }
90
+ catch { /* not a blocker */ }
91
+ }
92
+ this.ensuredPermissions = true;
58
93
  }
59
- catch { /* best-effort */ }
60
- // Seed counters from existing file
94
+ catch {
95
+ // Best-effort never crash the plugin for a log setup failure.
96
+ this.ensuredPermissions = false;
97
+ }
98
+ }
99
+ seedCountersFromDisk() {
61
100
  try {
62
- const stat = fs.statSync(logPath);
101
+ const stat = fs.statSync(this.logPath);
63
102
  this.approxSize = stat.size;
64
- const content = fs.readFileSync(logPath, 'utf-8');
103
+ const content = fs.readFileSync(this.logPath, 'utf-8');
65
104
  this.lineCount = content.split('\n').filter(l => l.length > 0).length;
66
105
  }
67
106
  catch {
@@ -69,9 +108,18 @@ class AuditLog {
69
108
  this.approxSize = 0;
70
109
  }
71
110
  }
111
+ /**
112
+ * Append a record. `filePath` may be empty — in that case we record the
113
+ * `<in-memory>` sentinel so the log doesn't emit an ambiguous trailing tab.
114
+ */
72
115
  append(sha256, filePath) {
73
- const line = `${new Date().toISOString()}\t${sha256}\t${filePath}\n`;
116
+ const displayPath = filePath && filePath.length > 0 ? filePath : '<in-memory>';
117
+ const line = `${new Date().toISOString()}\t${sha256}\t${displayPath}\n`;
74
118
  try {
119
+ if (!this.ensuredPermissions) {
120
+ // Container or file didn't exist at construction — retry now.
121
+ this.ensureContainerAndFile();
122
+ }
75
123
  fs.appendFileSync(this.logPath, line);
76
124
  this.lineCount++;
77
125
  this.approxSize += Buffer.byteLength(line);
@@ -85,9 +133,7 @@ class AuditLog {
85
133
  try {
86
134
  const content = fs.readFileSync(this.logPath, 'utf-8');
87
135
  const lines = content.split('\n').filter(l => l.length > 0);
88
- // Keep at most half of maxLines
89
136
  let keep = lines.slice(-Math.floor(this.maxLines / 2));
90
- // Also trim by size: drop oldest lines until under half of maxBytes
91
137
  const halfBytes = Math.floor(this.maxBytes / 2);
92
138
  while (keep.length > 1) {
93
139
  const size = Buffer.byteLength(keep.join('\n') + '\n');
@@ -96,7 +142,14 @@ class AuditLog {
96
142
  keep.shift();
97
143
  }
98
144
  const newContent = keep.join('\n') + '\n';
99
- fs.writeFileSync(this.logPath, newContent);
145
+ // Write with explicit owner-only mode — some Node versions reset
146
+ // the file's mode on writeFileSync with content, so set it again
147
+ // on the original descriptor path.
148
+ fs.writeFileSync(this.logPath, newContent, { mode: 0o600 });
149
+ try {
150
+ fs.chmodSync(this.logPath, 0o600);
151
+ }
152
+ catch { /* ignore */ }
100
153
  this.lineCount = keep.length;
101
154
  this.approxSize = Buffer.byteLength(newContent);
102
155
  }
@@ -105,8 +158,16 @@ class AuditLog {
105
158
  }
106
159
  exports.AuditLog = AuditLog;
107
160
  /**
108
- * Returns the directory for VT Sentinel logs (same as state dir).
161
+ * Returns the directory for VT Sentinel audit logs.
162
+ *
163
+ * v0.12.0 change: logs now live in a dedicated `<stateDir>/vt-sentinel-audit/`
164
+ * subdirectory (pre-0.12 they were at the stateDir root). The subdir is
165
+ * created at 0o700 on first write.
166
+ *
167
+ * The stateDir must be supplied by the caller (previously fell back to reading
168
+ * the environment; that read has been removed to keep this module free of
169
+ * `process.env` lookups that could co-occur with HTTP clients elsewhere).
109
170
  */
110
- function getLogDir() {
111
- return process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), '.openclaw');
171
+ function getLogDir(stateDir) {
172
+ return path.join(stateDir, 'vt-sentinel-audit');
112
173
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Compliance snapshot — the single source of truth that describes, in
3
+ * structured form, what VT Sentinel reads, writes, sends, and where it keeps
4
+ * its state.
5
+ *
6
+ * Consumed by three call sites:
7
+ * 1. `registerSecurityAuditCollector` — maps the snapshot to
8
+ * `SecurityAuditFinding[]` so `openclaw security audit --deep` can
9
+ * display it.
10
+ * 2. `vt_sentinel_status` — renders a "Compliance / Data Flow" block from
11
+ * the same data, so end users see the same picture the audit does.
12
+ * 3. Tests — assert the snapshot shape against mock configurations.
13
+ *
14
+ * This module is a pure function with no module-level side effects. Optional
15
+ * file-mode info can be collected by the caller via `collectLogModes()` and
16
+ * passed in; when absent, the snapshot simply omits permission checks.
17
+ */
18
+ import type { FullConfig, BlockMode, NotifyLevel, PresetName, AgentMetadataMode } from './config-manager';
19
+ import type { SensitiveFilePolicy } from './scanner';
20
+ export type CredentialMode = 'user_key' | 'vtai' | 'none';
21
+ export interface LogFileMode {
22
+ path: string;
23
+ exists: boolean;
24
+ mode?: string;
25
+ ownerPrivate: boolean;
26
+ }
27
+ export interface LogModesInfo {
28
+ auditDir: LogFileMode;
29
+ uploadsLog: LogFileMode;
30
+ detectionsLog: LogFileMode;
31
+ credentialsFile: LogFileMode;
32
+ stateFile: LogFileMode;
33
+ }
34
+ export interface ComplianceSnapshotInput {
35
+ config: FullConfig;
36
+ stateDir: string;
37
+ credentialMode: CredentialMode;
38
+ watchDirs: string[];
39
+ agentPublicHandle?: string;
40
+ /** Optional: pre-collected log permission info (see `collectLogModes`). */
41
+ logModes?: LogModesInfo;
42
+ }
43
+ export interface ComplianceSnapshot {
44
+ credentialMode: CredentialMode;
45
+ endpoints: {
46
+ virustotal: boolean;
47
+ virustotalAi: boolean;
48
+ /** npm registry only consulted from the explicit vt_sentinel_update tool */
49
+ npm: 'on-demand-only';
50
+ /** ClawHub only consulted from the explicit vt_sentinel_update tool */
51
+ clawhub: 'on-demand-only';
52
+ };
53
+ policies: {
54
+ autoScan: boolean;
55
+ sensitiveFilePolicy: SensitiveFilePolicy;
56
+ semanticFilePolicy: SensitiveFilePolicy;
57
+ blockMode: BlockMode;
58
+ notifyLevel: NotifyLevel;
59
+ preset: PresetName;
60
+ maxFileSizeMb: number;
61
+ showCleanScanLogs: boolean;
62
+ };
63
+ watchDirs: string[];
64
+ excludeDirs: string[];
65
+ excludeGlobs: string[];
66
+ paths: {
67
+ stateDir: string;
68
+ credentialsFile: string;
69
+ stateFile: string;
70
+ auditDir: string;
71
+ uploadsLog: string;
72
+ detectionsLog: string;
73
+ };
74
+ identity: {
75
+ /** display name declared to VTAI (either user-set or auto-generated) */
76
+ displayNameSet: boolean;
77
+ humanAliasSet: boolean;
78
+ bioSet: boolean;
79
+ contactEmailSet: boolean;
80
+ metadataMode: AgentMetadataMode;
81
+ publicHandle?: string;
82
+ };
83
+ logModes?: LogModesInfo;
84
+ baseline: AuditFinding[];
85
+ risks: AuditFinding[];
86
+ }
87
+ export interface AuditFinding {
88
+ checkId: string;
89
+ severity: 'info' | 'warn' | 'critical';
90
+ title: string;
91
+ detail: string;
92
+ remediation?: string;
93
+ }
94
+ export declare function computePaths(stateDir: string): ComplianceSnapshot['paths'];
95
+ /**
96
+ * Stat the log files + state files + audit dir to record their permissions.
97
+ * Safe to call even if none of them exist yet (returns `exists: false`).
98
+ */
99
+ export declare function collectLogModes(stateDir: string): LogModesInfo;
100
+ /**
101
+ * Build the compliance snapshot. Pure function — no I/O.
102
+ * Callers that want log-permission info should call `collectLogModes(stateDir)`
103
+ * first and pass the result as `input.logModes`.
104
+ */
105
+ export declare function buildComplianceSnapshot(input: ComplianceSnapshotInput): ComplianceSnapshot;
@@ -0,0 +1,303 @@
1
+ "use strict";
2
+ /**
3
+ * Compliance snapshot — the single source of truth that describes, in
4
+ * structured form, what VT Sentinel reads, writes, sends, and where it keeps
5
+ * its state.
6
+ *
7
+ * Consumed by three call sites:
8
+ * 1. `registerSecurityAuditCollector` — maps the snapshot to
9
+ * `SecurityAuditFinding[]` so `openclaw security audit --deep` can
10
+ * display it.
11
+ * 2. `vt_sentinel_status` — renders a "Compliance / Data Flow" block from
12
+ * the same data, so end users see the same picture the audit does.
13
+ * 3. Tests — assert the snapshot shape against mock configurations.
14
+ *
15
+ * This module is a pure function with no module-level side effects. Optional
16
+ * file-mode info can be collected by the caller via `collectLogModes()` and
17
+ * passed in; when absent, the snapshot simply omits permission checks.
18
+ */
19
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ var desc = Object.getOwnPropertyDescriptor(m, k);
22
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
23
+ desc = { enumerable: true, get: function() { return m[k]; } };
24
+ }
25
+ Object.defineProperty(o, k2, desc);
26
+ }) : (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ o[k2] = m[k];
29
+ }));
30
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
31
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
32
+ }) : function(o, v) {
33
+ o["default"] = v;
34
+ });
35
+ var __importStar = (this && this.__importStar) || (function () {
36
+ var ownKeys = function(o) {
37
+ ownKeys = Object.getOwnPropertyNames || function (o) {
38
+ var ar = [];
39
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
40
+ return ar;
41
+ };
42
+ return ownKeys(o);
43
+ };
44
+ return function (mod) {
45
+ if (mod && mod.__esModule) return mod;
46
+ var result = {};
47
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
48
+ __setModuleDefault(result, mod);
49
+ return result;
50
+ };
51
+ })();
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ exports.computePaths = computePaths;
54
+ exports.collectLogModes = collectLogModes;
55
+ exports.buildComplianceSnapshot = buildComplianceSnapshot;
56
+ const fs = __importStar(require("fs"));
57
+ const path = __importStar(require("path"));
58
+ // --- Core paths helper (pure) ---
59
+ function computePaths(stateDir) {
60
+ const auditDir = path.join(stateDir, 'vt-sentinel-audit');
61
+ return {
62
+ stateDir,
63
+ credentialsFile: path.join(stateDir, 'vt-sentinel-agent.json'),
64
+ stateFile: path.join(stateDir, 'vt-sentinel-state.json'),
65
+ auditDir,
66
+ uploadsLog: path.join(auditDir, 'uploads.log'),
67
+ detectionsLog: path.join(auditDir, 'detections.log'),
68
+ };
69
+ }
70
+ // --- Log-mode collector (optional, I/O) ---
71
+ /**
72
+ * Stat the log files + state files + audit dir to record their permissions.
73
+ * Safe to call even if none of them exist yet (returns `exists: false`).
74
+ */
75
+ function collectLogModes(stateDir) {
76
+ const p = computePaths(stateDir);
77
+ return {
78
+ auditDir: statMode(p.auditDir, true),
79
+ uploadsLog: statMode(p.uploadsLog, false),
80
+ detectionsLog: statMode(p.detectionsLog, false),
81
+ credentialsFile: statMode(p.credentialsFile, false),
82
+ stateFile: statMode(p.stateFile, false),
83
+ };
84
+ }
85
+ function statMode(fsPath, isDir) {
86
+ try {
87
+ const st = fs.statSync(fsPath);
88
+ const modeBits = st.mode & 0o777;
89
+ const mode = modeBits.toString(8).padStart(isDir ? 3 : 3, '0');
90
+ // Owner-private means: no group-read AND no other-read
91
+ // For dirs we also want no group-exec/other-exec.
92
+ const ownerPrivate = (modeBits & 0o077) === 0;
93
+ return { path: fsPath, exists: true, mode, ownerPrivate };
94
+ }
95
+ catch {
96
+ // Missing file / dir is fine — the plugin creates it lazily on first
97
+ // write with {mode: 0o600}/{mode: 0o700} from v0.12.0 onwards, so a
98
+ // non-existent log is implicitly "private once it exists".
99
+ return { path: fsPath, exists: false, ownerPrivate: true };
100
+ }
101
+ }
102
+ // --- Risk detection (pure helpers) ---
103
+ /** A watch dir is "risky" if it's close to the filesystem root or a whole user profile. */
104
+ function isBroadWatchDir(dir) {
105
+ if (!dir)
106
+ return false;
107
+ // Root '/' must be handled before trimming the trailing separator.
108
+ if (dir === '/' || /^[A-Za-z]:[\\/]?$/.test(dir))
109
+ return true;
110
+ const norm = dir.replace(/[\\/]+$/, ''); // trim trailing separator
111
+ // Unix near-root
112
+ if (['/home', '/Users'].includes(norm))
113
+ return true;
114
+ // Exact $HOME (common mistake — scans user profile including dotfiles)
115
+ // We can't resolve $HOME here without I/O; instead treat paths that end
116
+ // with just the user directory segment as broad.
117
+ // Heuristic: directory depth 2 or less (e.g. /Users/foo, /home/foo, /root).
118
+ const parts = norm.split(/[\\/]/).filter(Boolean);
119
+ if (parts.length <= 2 && /^(Users|home|root)$/.test(parts[0] ?? ''))
120
+ return true;
121
+ // Windows user dir at depth 2 (e.g. C:\Users\foo)
122
+ if (parts.length === 3 && /^[A-Za-z]:$/.test(parts[0] ?? '') && parts[1]?.toLowerCase() === 'users')
123
+ return true;
124
+ return false;
125
+ }
126
+ // --- Main builder ---
127
+ /**
128
+ * Build the compliance snapshot. Pure function — no I/O.
129
+ * Callers that want log-permission info should call `collectLogModes(stateDir)`
130
+ * first and pass the result as `input.logModes`.
131
+ */
132
+ function buildComplianceSnapshot(input) {
133
+ const { config, stateDir, credentialMode, watchDirs, agentPublicHandle, logModes } = input;
134
+ const paths = computePaths(stateDir);
135
+ const identity = {
136
+ displayNameSet: !!config.agentDisplayName,
137
+ humanAliasSet: !!config.agentHumanAlias,
138
+ bioSet: !!config.agentBio,
139
+ contactEmailSet: !!config.agentContactEmail,
140
+ metadataMode: config.agentMetadataMode ?? 'minimal',
141
+ publicHandle: agentPublicHandle,
142
+ };
143
+ const baseline = [];
144
+ const risks = [];
145
+ // --- Baseline info findings (what the plugin does) ---
146
+ baseline.push({
147
+ checkId: 'vt-sentinel.credential-mode',
148
+ severity: 'info',
149
+ title: `Credential mode: ${credentialMode}`,
150
+ detail: credentialMode === 'user_key'
151
+ ? 'Using a user-provided VirusTotal API key from plugin config. All scans go to www.virustotal.com.'
152
+ : credentialMode === 'vtai'
153
+ ? 'Using an auto-registered VTAI agent token (zero-config). All scans go to ai.virustotal.com.'
154
+ : 'No credentials active. Scanner disabled until a config apiKey is set or VTAI auto-registration completes.',
155
+ });
156
+ baseline.push({
157
+ checkId: 'vt-sentinel.endpoints',
158
+ severity: 'info',
159
+ title: 'Network endpoints contacted',
160
+ detail: (credentialMode === 'user_key' ? 'www.virustotal.com (scan requests)\n' : '') +
161
+ (credentialMode === 'vtai' ? 'ai.virustotal.com (scan requests)\n' : '') +
162
+ 'registry.npmjs.org + clawhub.ai — only when the user explicitly invokes vt_sentinel_update.',
163
+ });
164
+ baseline.push({
165
+ checkId: 'vt-sentinel.auto-scan',
166
+ severity: 'info',
167
+ title: `Auto-scan: ${config.autoScan ? 'enabled' : 'disabled'}`,
168
+ detail: config.autoScan
169
+ ? `Watching ${watchDirs.length} director${watchDirs.length === 1 ? 'y' : 'ies'}. block mode: ${config.blockMode}. notify level: ${config.notifyLevel}.`
170
+ : `Active protection hooks remain registered (block mode: ${config.blockMode}) but no background watcher is running.`,
171
+ });
172
+ baseline.push({
173
+ checkId: 'vt-sentinel.upload-policies',
174
+ severity: 'info',
175
+ title: 'Upload policies',
176
+ detail: `sensitive (PDF/Office/unknown archives): ${config.sensitiveFilePolicy}\n` +
177
+ `semantic (SKILL.md, HOOK.md, AGENTS.md, etc.): ${config.semanticFilePolicy}\n` +
178
+ `preset: ${config.configPreset}, maxFileSizeMb: ${config.maxFileSizeMb}`,
179
+ });
180
+ baseline.push({
181
+ checkId: 'vt-sentinel.state-paths',
182
+ severity: 'info',
183
+ title: 'State and log file paths',
184
+ detail: `credentials: ${paths.credentialsFile} (0o600)\n` +
185
+ `runtime state: ${paths.stateFile}\n` +
186
+ `audit logs: ${paths.auditDir} (uploads.log, detections.log, target 0o600)`,
187
+ });
188
+ baseline.push({
189
+ checkId: 'vt-sentinel.identity-metadata',
190
+ severity: 'info',
191
+ title: `VTAI identity metadata: ${identity.metadataMode}`,
192
+ detail: `displayName set: ${identity.displayNameSet}\n` +
193
+ `humanAlias set: ${identity.humanAliasSet}\n` +
194
+ `bio set: ${identity.bioSet}\n` +
195
+ `contactEmail set: ${identity.contactEmailSet}` +
196
+ (identity.publicHandle ? `\npublic handle: ${identity.publicHandle}` : ''),
197
+ });
198
+ // --- Risk flags (warnings) ---
199
+ if (credentialMode === 'none') {
200
+ risks.push({
201
+ checkId: 'vt-sentinel.no-credentials',
202
+ severity: 'warn',
203
+ title: 'No active VirusTotal credentials',
204
+ detail: 'The plugin is loaded but cannot run scans until credentials are configured or VTAI auto-registration succeeds.',
205
+ remediation: 'Either invoke any scan tool to trigger VTAI auto-registration, or set plugins.entries.openclaw-plugin-vt-sentinel.config.apiKey via `openclaw config set`.',
206
+ });
207
+ }
208
+ if (config.sensitiveFilePolicy === 'always_upload') {
209
+ risks.push({
210
+ checkId: 'vt-sentinel.always-upload-sensitive',
211
+ severity: 'warn',
212
+ title: 'Sensitive files auto-uploaded without consent',
213
+ detail: 'sensitiveFilePolicy=always_upload means PDFs, Office docs, and unknown archives are sent to VirusTotal with no per-file prompt.',
214
+ remediation: 'Switch to `ask`, `ask_once`, or `hash_only` via vt_sentinel_configure.',
215
+ });
216
+ }
217
+ if (config.semanticFilePolicy === 'always_upload') {
218
+ risks.push({
219
+ checkId: 'vt-sentinel.always-upload-semantic',
220
+ severity: 'warn',
221
+ title: 'Instruction files auto-uploaded without consent',
222
+ detail: 'semanticFilePolicy=always_upload means SKILL.md, HOOK.md, AGENTS.md, etc. are sent to VirusTotal with no per-file prompt. These often contain private operational data.',
223
+ remediation: 'Switch to `hash_only` (recommended default) or `ask` via vt_sentinel_configure.',
224
+ });
225
+ }
226
+ const broadDirs = watchDirs.filter(isBroadWatchDir);
227
+ if (broadDirs.length > 0) {
228
+ risks.push({
229
+ checkId: 'vt-sentinel.broad-watch-dirs',
230
+ severity: 'warn',
231
+ title: 'Very broad directory under watch',
232
+ detail: `Watching: ${broadDirs.join(', ')}. A root-level or whole-profile watcher can generate noisy scan traffic and increases the blast radius of upload policies.`,
233
+ remediation: 'Narrow watchDirs to specific project / download folders via vt_sentinel_configure.',
234
+ });
235
+ }
236
+ if (config.agentContactEmail) {
237
+ risks.push({
238
+ checkId: 'vt-sentinel.contact-email-shared',
239
+ severity: 'warn',
240
+ title: 'Contact email shared with VTAI',
241
+ detail: 'agentContactEmail is set, so the address is sent to VTAI on agent registration. This is your choice but worth surfacing explicitly.',
242
+ remediation: 'Remove agentContactEmail from config if you prefer no PII in the VTAI registration.',
243
+ });
244
+ }
245
+ if (!config.autoScan && config.blockMode === 'quarantine') {
246
+ risks.push({
247
+ checkId: 'vt-sentinel.passive-with-quarantine',
248
+ severity: 'warn',
249
+ title: 'Quarantine mode active without auto-scan',
250
+ detail: 'blockMode=quarantine will isolate files matched by the blocklist, but autoScan=false means new files are not proactively scanned. Threats not yet in the blocklist will not be caught.',
251
+ remediation: 'Either enable autoScan or switch blockMode to `log_only`/`block_only` to align the posture.',
252
+ });
253
+ }
254
+ if (logModes) {
255
+ const nonPrivate = [];
256
+ if (logModes.credentialsFile.exists && !logModes.credentialsFile.ownerPrivate)
257
+ nonPrivate.push(`credentials (${logModes.credentialsFile.mode})`);
258
+ if (logModes.stateFile.exists && !logModes.stateFile.ownerPrivate)
259
+ nonPrivate.push(`state (${logModes.stateFile.mode})`);
260
+ if (logModes.uploadsLog.exists && !logModes.uploadsLog.ownerPrivate)
261
+ nonPrivate.push(`uploads.log (${logModes.uploadsLog.mode})`);
262
+ if (logModes.detectionsLog.exists && !logModes.detectionsLog.ownerPrivate)
263
+ nonPrivate.push(`detections.log (${logModes.detectionsLog.mode})`);
264
+ if (logModes.auditDir.exists && !logModes.auditDir.ownerPrivate)
265
+ nonPrivate.push(`audit dir (${logModes.auditDir.mode})`);
266
+ if (nonPrivate.length > 0) {
267
+ risks.push({
268
+ checkId: 'vt-sentinel.logs-not-private',
269
+ severity: 'warn',
270
+ title: 'State or log files are not owner-private',
271
+ detail: `The following files permit group/other read: ${nonPrivate.join(', ')}. Credentials and detection history should be 0o600 (files) / 0o700 (dirs).`,
272
+ remediation: 'On POSIX, run `chmod 600` on the files and `chmod 700` on the audit dir. New files created by this plugin (v0.12.0+) are already 0o600 on creation.',
273
+ });
274
+ }
275
+ }
276
+ return {
277
+ credentialMode,
278
+ endpoints: {
279
+ virustotal: credentialMode === 'user_key',
280
+ virustotalAi: credentialMode === 'vtai',
281
+ npm: 'on-demand-only',
282
+ clawhub: 'on-demand-only',
283
+ },
284
+ policies: {
285
+ autoScan: config.autoScan,
286
+ sensitiveFilePolicy: config.sensitiveFilePolicy,
287
+ semanticFilePolicy: config.semanticFilePolicy,
288
+ blockMode: config.blockMode,
289
+ notifyLevel: config.notifyLevel,
290
+ preset: config.configPreset,
291
+ maxFileSizeMb: config.maxFileSizeMb,
292
+ showCleanScanLogs: config.showCleanScanLogs,
293
+ },
294
+ watchDirs: [...watchDirs],
295
+ excludeDirs: [...(config.excludeDirs ?? [])],
296
+ excludeGlobs: [...(config.excludeGlobs ?? [])],
297
+ paths,
298
+ identity,
299
+ logModes,
300
+ baseline,
301
+ risks,
302
+ };
303
+ }
package/dist/index.d.ts CHANGED
@@ -40,6 +40,25 @@ interface PluginApi {
40
40
  }) => void;
41
41
  registerHook?: (events: string | string[], handler: (event: any) => Promise<any>, opts?: object) => void;
42
42
  onToolResult?: (handler: (event: any) => Promise<any>) => void;
43
+ registerSecurityAuditCollector?: (collector: (ctx: {
44
+ config: any;
45
+ sourceConfig: any;
46
+ env: NodeJS.ProcessEnv;
47
+ stateDir: string;
48
+ configPath: string;
49
+ }) => Array<{
50
+ checkId: string;
51
+ severity: 'info' | 'warn' | 'critical';
52
+ title: string;
53
+ detail: string;
54
+ remediation?: string;
55
+ }> | Promise<Array<{
56
+ checkId: string;
57
+ severity: 'info' | 'warn' | 'critical';
58
+ title: string;
59
+ detail: string;
60
+ remediation?: string;
61
+ }>>) => void;
43
62
  }
44
63
  /**
45
64
  * Simple semver comparison: returns true if `latest` is newer than `current`.
package/dist/index.js CHANGED
@@ -52,6 +52,7 @@ const vt_api_1 = require("./vt-api");
52
52
  const env_access_1 = require("./env-access");
53
53
  const version_1 = require("./version");
54
54
  const update_commands_1 = require("./update-commands");
55
+ const compliance_snapshot_1 = require("./compliance-snapshot");
55
56
  const audit_log_1 = require("./audit-log");
56
57
  const config_manager_1 = require("./config-manager");
57
58
  const state_store_1 = require("./state-store");
@@ -354,9 +355,11 @@ function vtSentinelPlugin(api) {
354
355
  return true;
355
356
  };
356
357
  // --- Audit logs: rotating logs for uploads and detections ---
357
- const logDir = (0, audit_log_1.getLogDir)();
358
- const uploadLog = new audit_log_1.AuditLog(path.join(logDir, 'vt-sentinel-uploads.log'));
359
- const detectionLog = new audit_log_1.AuditLog(path.join(logDir, 'vt-sentinel-detections.log'));
358
+ // v0.12.0: logs live in <stateDir>/vt-sentinel-audit/ (subdir at 0o700,
359
+ // files pre-created at 0o600 — see src/audit-log.ts for the hardening).
360
+ const logDir = (0, audit_log_1.getLogDir)(resolvedStateDir);
361
+ const uploadLog = new audit_log_1.AuditLog(path.join(logDir, 'uploads.log'));
362
+ const detectionLog = new audit_log_1.AuditLog(path.join(logDir, 'detections.log'));
360
363
  /** Log a scan result to the appropriate audit log(s). */
361
364
  const auditResult = (result) => {
362
365
  if (!result.sha256)
@@ -737,6 +740,50 @@ function vtSentinelPlugin(api) {
737
740
  api.logger.info('[VT-Sentinel] Service stopped');
738
741
  },
739
742
  });
743
+ // --- Security audit collector (v0.12.0 transparency surface) ---
744
+ //
745
+ // Reports what VT Sentinel reads, writes, sends, and flags config that
746
+ // carries user-visible risk (auto-uploads, broad watchers, non-private
747
+ // state files, PII in VTAI identity, inconsistent block vs. scan posture).
748
+ // Surfaced via `openclaw security audit --deep --json`. All data is derived
749
+ // from the same `buildComplianceSnapshot(...)` helper that renders the
750
+ // Compliance block in `vt_sentinel_status`, so both views stay in sync.
751
+ if (typeof api.registerSecurityAuditCollector === 'function') {
752
+ api.registerSecurityAuditCollector((ctx) => {
753
+ try {
754
+ const mode = credentialMode === 'user_key' ? 'user_key' :
755
+ credentialMode === 'vtai' ? 'vtai' : 'none';
756
+ const eff = configManager.getEffective();
757
+ // Use the audit context's stateDir when provided (it already
758
+ // honors profile overrides); fall back to our resolvedStateDir.
759
+ const sd = ctx.stateDir || resolvedStateDir;
760
+ const logModes = (0, compliance_snapshot_1.collectLogModes)(sd);
761
+ const snap = (0, compliance_snapshot_1.buildComplianceSnapshot)({
762
+ config: eff,
763
+ stateDir: sd,
764
+ credentialMode: mode,
765
+ watchDirs: [...watchRoots],
766
+ agentPublicHandle: (0, vt_api_1.loadAgentCredentials)()?.publicHandle,
767
+ logModes,
768
+ });
769
+ return [...snap.baseline, ...snap.risks].map(f => ({
770
+ checkId: f.checkId,
771
+ severity: f.severity,
772
+ title: f.title,
773
+ detail: f.detail,
774
+ ...(f.remediation ? { remediation: f.remediation } : {}),
775
+ }));
776
+ }
777
+ catch (err) {
778
+ return [{
779
+ checkId: 'vt-sentinel.audit-collector-error',
780
+ severity: 'warn',
781
+ title: 'VT Sentinel compliance snapshot failed to build',
782
+ detail: `Collector threw while assembling audit findings: ${err?.message || String(err)}`,
783
+ }];
784
+ }
785
+ });
786
+ }
740
787
  // --- Tool: vt_scan_file ---
741
788
  api.registerTool({
742
789
  name: 'vt_scan_file',
@@ -901,6 +948,18 @@ function vtSentinelPlugin(api) {
901
948
  parameters: { type: 'object', properties: {}, required: [] },
902
949
  execute: async (_ctx, _params) => {
903
950
  const eff = configManager.getEffective();
951
+ // Build the compliance snapshot from the same source used by the
952
+ // security audit collector, so the two views never drift.
953
+ const snapMode = credentialMode === 'user_key' ? 'user_key' :
954
+ credentialMode === 'vtai' ? 'vtai' : 'none';
955
+ const snap = (0, compliance_snapshot_1.buildComplianceSnapshot)({
956
+ config: eff,
957
+ stateDir: resolvedStateDir,
958
+ credentialMode: snapMode,
959
+ watchDirs: [...watchRoots],
960
+ agentPublicHandle: (0, vt_api_1.loadAgentCredentials)()?.publicHandle,
961
+ logModes: (0, compliance_snapshot_1.collectLogModes)(resolvedStateDir),
962
+ });
904
963
  return textResponse((0, status_renderer_1.renderStatus)({
905
964
  version: (0, version_1.getCurrentVersion)(),
906
965
  apiMode: credentialMode === 'vtai' ? 'vtai' : 'user_key',
@@ -918,6 +977,23 @@ function vtSentinelPlugin(api) {
918
977
  metadataMode: eff.agentMetadataMode || 'minimal',
919
978
  humanAlias: eff.agentHumanAlias,
920
979
  },
980
+ compliance: {
981
+ endpoints: snap.endpoints,
982
+ credentialMode: snap.credentialMode,
983
+ paths: {
984
+ credentialsFile: snap.paths.credentialsFile,
985
+ stateFile: snap.paths.stateFile,
986
+ auditDir: snap.paths.auditDir,
987
+ },
988
+ identity: {
989
+ displayNameSet: snap.identity.displayNameSet,
990
+ humanAliasSet: snap.identity.humanAliasSet,
991
+ bioSet: snap.identity.bioSet,
992
+ contactEmailSet: snap.identity.contactEmailSet,
993
+ metadataMode: snap.identity.metadataMode,
994
+ },
995
+ risks: snap.risks,
996
+ },
921
997
  }));
922
998
  },
923
999
  });
@@ -6,6 +6,32 @@ export declare function renderOnboarding(opts: {
6
6
  effectiveConfig: FullConfig;
7
7
  availableTools: string[];
8
8
  }): string;
9
+ export interface ComplianceRenderBlock {
10
+ endpoints: {
11
+ virustotal: boolean;
12
+ virustotalAi: boolean;
13
+ };
14
+ credentialMode: 'user_key' | 'vtai' | 'none';
15
+ paths: {
16
+ credentialsFile: string;
17
+ stateFile: string;
18
+ auditDir: string;
19
+ };
20
+ identity: {
21
+ displayNameSet: boolean;
22
+ humanAliasSet: boolean;
23
+ bioSet: boolean;
24
+ contactEmailSet: boolean;
25
+ metadataMode: string;
26
+ };
27
+ risks: Array<{
28
+ checkId: string;
29
+ severity: string;
30
+ title: string;
31
+ detail: string;
32
+ remediation?: string;
33
+ }>;
34
+ }
9
35
  export declare function renderStatus(opts: {
10
36
  version: string;
11
37
  apiMode: 'user_key' | 'vtai';
@@ -23,6 +49,7 @@ export declare function renderStatus(opts: {
23
49
  metadataMode: string;
24
50
  humanAlias?: string;
25
51
  };
52
+ compliance?: ComplianceRenderBlock;
26
53
  }): string;
27
54
  export declare function renderPolicyMatrix(config: FullConfig): string;
28
55
  export declare function renderHelp(): string;
@@ -33,7 +33,6 @@ function renderOnboarding(opts) {
33
33
  lines.push('Run vt_sentinel_status for full status. Run vt_sentinel_help for usage guide.');
34
34
  return lines.join('\n');
35
35
  }
36
- // --- Status ---
37
36
  function renderStatus(opts) {
38
37
  const lines = [];
39
38
  const cfg = opts.effectiveConfig;
@@ -95,6 +94,39 @@ function renderStatus(opts) {
95
94
  lines.push('Runtime State:');
96
95
  lines.push(` Blocked files: ${opts.blockedFileCount}`);
97
96
  lines.push('');
97
+ // Compliance / Data Flow (v0.12.0+)
98
+ if (opts.compliance) {
99
+ const c = opts.compliance;
100
+ lines.push('Compliance / Data Flow:');
101
+ const endpoints = [];
102
+ if (c.endpoints.virustotal)
103
+ endpoints.push('www.virustotal.com');
104
+ if (c.endpoints.virustotalAi)
105
+ endpoints.push('ai.virustotal.com');
106
+ lines.push(` Network (live): ${endpoints.length ? endpoints.join(', ') : '(none — scanner not configured)'}`);
107
+ lines.push(` Network (on-demand only): registry.npmjs.org, clawhub.ai (via vt_sentinel_update)`);
108
+ lines.push(` Credential mode: ${c.credentialMode}`);
109
+ lines.push(` Credentials file: ${c.paths.credentialsFile} (target 0o600)`);
110
+ lines.push(` Runtime state file: ${c.paths.stateFile}`);
111
+ lines.push(` Audit logs dir: ${c.paths.auditDir} (target 0o700; uploads.log + detections.log at 0o600)`);
112
+ lines.push(` VTAI identity metadata sent: ${c.identity.metadataMode}` +
113
+ ` (displayName=${c.identity.displayNameSet ? 'set' : 'none'},` +
114
+ ` humanAlias=${c.identity.humanAliasSet ? 'set' : 'none'},` +
115
+ ` bio=${c.identity.bioSet ? 'set' : 'none'},` +
116
+ ` email=${c.identity.contactEmailSet ? 'set' : 'none'})`);
117
+ if (c.risks.length === 0) {
118
+ lines.push(' Risk flags: none');
119
+ }
120
+ else {
121
+ lines.push(` Risk flags (${c.risks.length}):`);
122
+ for (const r of c.risks) {
123
+ lines.push(` [${r.severity}] ${r.title}`);
124
+ if (r.remediation)
125
+ lines.push(` → ${r.remediation}`);
126
+ }
127
+ }
128
+ lines.push('');
129
+ }
98
130
  // How to change
99
131
  lines.push('To change config: use vt_sentinel_configure tool');
100
132
  lines.push('To reset: use vt_sentinel_reset_policy tool');
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-plugin-vt-sentinel",
3
3
  "name": "VT Sentinel",
4
4
  "description": "VirusTotal Sentinel for OpenClaw — malware detection, active protection, and AI-powered code analysis.",
5
- "version": "0.11.3",
5
+ "version": "0.12.0",
6
6
  "skills": ["./skills"],
7
7
  "contracts": {
8
8
  "tools": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-plugin-vt-sentinel",
3
- "version": "0.11.3",
3
+ "version": "0.12.0",
4
4
  "displayName": "VT Sentinel",
5
5
  "description": "VirusTotal Sentinel for OpenClaw - Malware detection and AI-powered code analysis",
6
6
  "main": "dist/index.js",
@@ -51,9 +51,9 @@
51
51
  "dist/env-access.*",
52
52
  "dist/version.*",
53
53
  "dist/update-commands.*",
54
+ "dist/compliance-snapshot.*",
54
55
  "dist/signatures/**/*.json",
55
56
  "skills/",
56
- "hooks/",
57
57
  "openclaw.plugin.json"
58
58
  ],
59
59
  "openclaw": {
@@ -1,33 +0,0 @@
1
- ---
2
- name: vt-auto-scan
3
- description: >-
4
- Automatically scans files created or downloaded by agent tool calls
5
- (exec, write, web_fetch) using VirusTotal. Detects malware downloads,
6
- malicious scripts, and compromised skill files in real-time.
7
- emoji: "\U0001F6E1\uFE0F"
8
- events:
9
- - tool_result_persist
10
- ---
11
-
12
- # VT Auto-Scan Hook
13
-
14
- Intercepts tool execution results and automatically scans any files that were
15
- created, downloaded, or modified during the tool call. This provides a
16
- transparent security layer that catches malicious payloads regardless of
17
- which skill or command originated the download.
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
-
27
- ## Detected Patterns
28
-
29
- - `curl -o /path/file`, `wget -O /path/file` — download targets
30
- - `> /path/file`, `tee /path/file` — redirect outputs
31
- - `bash /path/script.sh`, `python /path/script.py` — executed scripts
32
- - Files written via `write` / `edit` tools
33
- - File paths appearing in tool output in monitored directories
@@ -1,174 +0,0 @@
1
- /**
2
- * Standalone hook handler for vt-auto-scan.
3
- * This handler works when loaded independently by OpenClaw's hook discovery.
4
- * It creates its own scanner instance from the environment.
5
- *
6
- * Supports both standard VT API (user key) and VTAI (cached agent credentials).
7
- * For the integrated plugin path, the hook is registered directly in index.ts.
8
- */
9
-
10
- const path = require('path');
11
- const fs = require('fs');
12
-
13
- let Scanner, extractPaths, loadAgentCredentials, ConfigManager, matchGlob, isSelfPath, StateStore;
14
-
15
- try {
16
- // Try to load from the compiled plugin
17
- const distDir = path.resolve(__dirname, '../../dist');
18
- Scanner = require(path.join(distDir, 'scanner')).Scanner;
19
- extractPaths = require(path.join(distDir, 'path-extractor')).extractPaths;
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;
25
- } catch (e) {
26
- // Modules not available — this hook won't function standalone
27
- console.error('[vt-auto-scan] Could not load scanner modules:', e.message);
28
- }
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
-
49
- /**
50
- * Create a scanner instance using available credentials.
51
- * Priority: user apiKey from static config > cached VTAI agent token.
52
- *
53
- * v0.11.0 change: removed the `process.env.VIRUSTOTAL_API_KEY` lookup + the
54
- * `'vtai-active'` env sentinel. The main plugin no longer mutates the
55
- * environment, so this standalone hook mirrors that stance: credential source
56
- * is 100% derived from openclaw.json (user apiKey) or the persisted agent
57
- * credentials file (VTAI mode).
58
- */
59
- function createScanner(logger, eff, staticConfig) {
60
- const userKey = staticConfig && typeof staticConfig.apiKey === 'string' && staticConfig.apiKey.trim().length > 0
61
- ? staticConfig.apiKey.trim()
62
- : null;
63
-
64
- if (userKey) {
65
- return new Scanner(userKey, logger, eff.maxFileSizeMb, eff.sensitiveFilePolicy, false, eff.semanticFilePolicy);
66
- }
67
-
68
- // Try VTAI cached credentials
69
- if (loadAgentCredentials) {
70
- try {
71
- const creds = loadAgentCredentials();
72
- if (creds) {
73
- return new Scanner(creds.agentToken, logger, eff.maxFileSizeMb, eff.sensitiveFilePolicy, true, eff.semanticFilePolicy);
74
- }
75
- } catch (e) {
76
- // Credentials not available
77
- }
78
- }
79
-
80
- return null;
81
- }
82
-
83
- const handler = async (event) => {
84
- // Only handle tool result events
85
- if (event.type !== 'tool_result_persist' && event.type !== 'tool_result') return;
86
-
87
- if (!Scanner || !extractPaths) return;
88
-
89
- // Load config (static + persisted overrides) and check autoScan
90
- let configManager = null;
91
- const staticConfig = readStaticConfig();
92
- if (ConfigManager) {
93
- configManager = new ConfigManager(staticConfig);
94
- // Apply persisted runtime overrides from vt-sentinel-state.json
95
- if (StateStore) {
96
- try {
97
- const stateStore = new StateStore();
98
- configManager.loadPersistedOverrides(stateStore.getPersistedOverrides());
99
- } catch {
100
- // State file missing or corrupt — proceed with static config only
101
- }
102
- }
103
- }
104
- const eff = configManager ? configManager.getEffective() : {
105
- autoScan: true, maxFileSizeMb: 32,
106
- sensitiveFilePolicy: 'ask', semanticFilePolicy: 'hash_only',
107
- excludeGlobs: [],
108
- };
109
-
110
- // autoScan=false disables hook scanning
111
- if (!eff.autoScan) return;
112
-
113
- const logger = {
114
- info: (m) => console.log(m),
115
- warn: (m) => console.warn(m),
116
- error: (m) => console.error(m),
117
- };
118
-
119
- const scanner = createScanner(logger, eff, staticConfig);
120
- if (!scanner) return;
121
-
122
- const toolName = event.toolName || event.tool || '';
123
- const toolParams = event.toolParams || event.params || event.input || {};
124
-
125
- // Extract result text
126
- let resultText = '';
127
- if (typeof event.toolResult === 'string') resultText = event.toolResult;
128
- else if (event.toolResult?.stdout) resultText = event.toolResult.stdout;
129
- else if (event.toolResult?.content && Array.isArray(event.toolResult.content)) {
130
- resultText = event.toolResult.content
131
- .filter(p => p.type === 'text')
132
- .map(p => p.text)
133
- .join('\n');
134
- }
135
-
136
- const targets = extractPaths(toolName, toolParams, resultText);
137
- if (targets.length === 0) return;
138
-
139
- for (const target of targets) {
140
- // Self-exclusion
141
- if (isSelfPath && isSelfPath(target.path)) continue;
142
-
143
- // excludeGlobs: skip files matching any exclude pattern
144
- if (matchGlob && eff.excludeGlobs && eff.excludeGlobs.length > 0) {
145
- let excluded = false;
146
- for (const glob of eff.excludeGlobs) {
147
- if (matchGlob(target.path, glob)) { excluded = true; break; }
148
- }
149
- if (excluded) continue;
150
- }
151
-
152
- try {
153
- // read_target → hash-only (never upload files the agent merely reads)
154
- const isReadTarget = target.source === 'read_target';
155
- const result = await scanner.scanFile(target.path, false, undefined, isReadTarget);
156
- if (result.verdict === 'malicious' || result.verdict === 'suspicious') {
157
- const warning = `\n\n⚠️ VT-SENTINEL SECURITY ALERT ⚠️\n` +
158
- `File: ${result.fileName} | Verdict: ${result.verdict.toUpperCase()}\n` +
159
- `${result.vtLink || ''}`;
160
-
161
- if (event.toolResult?.content && Array.isArray(event.toolResult.content)) {
162
- event.toolResult.content.push({ type: 'text', text: warning });
163
- }
164
-
165
- logger.error(`[VT-Sentinel] ${result.verdict.toUpperCase()}: ${result.fileName} — ${result.message}`);
166
- }
167
- } catch (err) {
168
- logger.error(`[vt-auto-scan] Scan error: ${err.message}`);
169
- }
170
- }
171
- };
172
-
173
- module.exports = handler;
174
- module.exports.default = handler;