openclaw-plugin-vt-sentinel 0.11.3 → 0.12.1

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,110 @@
2
2
 
3
3
  All notable changes to `openclaw-plugin-vt-sentinel`.
4
4
 
5
+ ## 0.12.1 — Security audit collector: dual-path wiring
6
+
7
+ Fix for the collector introduced in 0.12.0. The in-gateway registration via
8
+ `api.registerSecurityAuditCollector` was insufficient: `openclaw security audit
9
+ --deep` runs in a fresh Node process that doesn't share state with the gateway
10
+ and reads collectors from the plugin's **module-level** `securityAuditCollectors`
11
+ field instead. Verified empirically on the production Linux VM (OpenClaw
12
+ 2026.4.12): 0.12.0 shipped with a runtime-only collector that emitted zero
13
+ `vt-sentinel.*` findings under `openclaw security audit --deep --json`.
14
+
15
+ ### Fixed
16
+
17
+ - **`securityAuditCollectors` declared at module level.** The default export
18
+ is now a plugin-definition object: `{ id, name, register, securityAuditCollectors }`.
19
+ The CLI audit picks up the collector from the object's field; the gateway
20
+ still registers it at runtime via `api.registerSecurityAuditCollector` for
21
+ any in-process audit surface that uses `getActivePluginRegistry()`. Both
22
+ paths now light up.
23
+ - **`vtSentinelAuditCollector` is self-contained.** It rebuilds the
24
+ compliance snapshot exclusively from `ctx.config`, `ctx.stateDir`, and
25
+ `ctx.configPath` — no closure state, no shared-module variables. Usable
26
+ from a cold-loaded plugin metadata snapshot.
27
+ - Runtime behavior (scanning, hooks, policies) unchanged.
28
+
29
+ ## 0.12.0 — Transparency surface + log hygiene
30
+
31
+ **Headline:** what VT Sentinel does at runtime is now auditable from two
32
+ places: `vt_sentinel_status` and `openclaw security audit --deep --json`.
33
+ Both views are rendered from the same snapshot function, so they can't drift.
34
+
35
+ ### Added
36
+
37
+ - **`registerSecurityAuditCollector`** (new). Registers a plugin-scoped
38
+ collector that OpenClaw surfaces under `openclaw security audit --deep`.
39
+ Emits `info`-level findings for credential mode, endpoints, effective
40
+ policies, state/log paths, and identity metadata; emits `warn`-level
41
+ findings for risky config: `always_upload` on sensitive or semantic
42
+ files, broad watch dirs (root-level or whole-profile), audit files not
43
+ owner-private, `agentContactEmail` set (PII flagged as user's choice),
44
+ and the inconsistent `autoScan=false` + `blockMode=quarantine` posture.
45
+ - **`buildComplianceSnapshot`** — pure function in
46
+ `src/compliance-snapshot.ts`. Single source of truth consumed by the
47
+ security audit collector, the `vt_sentinel_status` tool output, and
48
+ tests. Uses `ctx.stateDir` / `ctx.configPath` — no global reads.
49
+ - **`vt_sentinel_status` Compliance / Data Flow block.** Renders live
50
+ endpoints, credential mode, state and log file paths, VTAI identity
51
+ metadata shape (never the values), and any active risk flags.
52
+ - **README Privacy & compliance section** with a data-flow table (what's
53
+ read / uploaded / where credentials live / how to opt out) and the
54
+ explicit note that `VIRUSTOTAL_API_KEY` was retired in 0.11.x.
55
+
56
+ ### Changed
57
+
58
+ - **Audit-log hygiene.** Log files (`uploads.log`, `detections.log`) are
59
+ pre-created at mode `0o600` so the first append cannot race the process
60
+ umask. The audit directory is created at `0o700`. Rotations rewrite
61
+ with an explicit owner-only mode and tighten again via `chmodSync`
62
+ on platforms where `writeFileSync` resets the mode.
63
+ - **Audit logs moved to `<stateDir>/vt-sentinel-audit/`.** Previously
64
+ `<stateDir>/vt-sentinel-uploads.log` and `...-detections.log`. Clean
65
+ move because production users had not yet produced entries (no
66
+ migration needed for most installs).
67
+ - **Empty file path is now recorded as `<in-memory>`.** Detections that
68
+ surface a SHA-256 without an on-disk path (extracted archive members,
69
+ EICAR-from-buffer scans) no longer emit an ambiguous trailing tab.
70
+ - **`getLogDir(stateDir)` takes an explicit argument.** The former
71
+ `process.env.OPENCLAW_STATE_DIR` fallback was removed for module
72
+ uniformity with the v0.11.x env-free stance.
73
+ - **Standalone `hooks/vt-auto-scan/` retired.** OpenClaw scans
74
+ `hooks/*/HOOK.md` for hook-pack discovery, which created a duplicate
75
+ registration alongside the runtime `api.registerHook(...)` calls in
76
+ `index.ts`. With `install.minHostVersion >=2026.3.22` guaranteed, the
77
+ runtime path is sufficient. One authoritative source, half the
78
+ maintenance surface.
79
+
80
+ ### Internal
81
+
82
+ - `src/update-commands.ts` — split from `index.ts` in 0.11.3 for scanner
83
+ hygiene; now also used by the compliance snapshot tests as an example
84
+ of the "pure helper" pattern we apply to new modules.
85
+
86
+ ### Deferred
87
+
88
+ - **Migration to `definePluginEntry`.** Verified empirically on the Linux
89
+ production VM (OpenClaw 2026.4.12): `require('openclaw/plugin-sdk/core')`
90
+ does not resolve from `~/.openclaw/extensions/<plugin>/dist/`. Would
91
+ require a peerDependency + NODE_PATH surgery with only cosmetic benefit.
92
+ The plain default-export plugin shape stays. Re-evaluate when OpenClaw
93
+ ships a formal plugin-sdk resolution helper.
94
+ - **SecretRef for `apiKey`.** Still blocked upstream —
95
+ `validatePluginConfig` in OpenClaw 2026.4.12 does not auto-resolve
96
+ SecretRefs for non-channel plugins.
97
+ - **`potential-exfiltration` in `dist/vt-api.js`.** The same finding
98
+ remains from 0.11.3: credential persistence used to live next to axios
99
+ calls. Since v0.11.3 split them into `vt-credentials.ts`, static scan
100
+ now reports clean. No action needed in 0.12.0.
101
+
102
+ ### Compatibility
103
+
104
+ Runtime-compatible with 0.11.x users. Existing credentials, runtime
105
+ overrides, and cached agent identities carry across the upgrade. The
106
+ two log-file paths are new; legacy files at the old stateDir root remain
107
+ as unused orphans and can be deleted manually if desired.
108
+
5
109
  ## 0.11.3 — ClawHub static-scan: eliminate last warn
6
110
 
7
111
  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,118 @@
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;
106
+ interface AuditCollectorCtx {
107
+ config: any;
108
+ sourceConfig: any;
109
+ env: NodeJS.ProcessEnv;
110
+ stateDir: string;
111
+ configPath: string;
112
+ }
113
+ /**
114
+ * Static security-audit collector. Pass this to OpenClaw via the plugin's
115
+ * module-level default export (`securityAuditCollectors: [vtSentinelAuditCollector]`).
116
+ */
117
+ export declare function vtSentinelAuditCollector(ctx: AuditCollectorCtx): AuditFinding[];
118
+ export {};