openclaw-plugin-vt-sentinel 0.11.2 → 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,101 @@
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
+
85
+ ## 0.11.3 — ClawHub static-scan: eliminate last warn
86
+
87
+ Runtime behavior identical to 0.11.2. One last structural change so ClawHub's
88
+ static scanner lands at `status: clean`.
89
+
90
+ ### Fixed
91
+
92
+ - **`vt_sentinel_update` tool moved to its own module.** The bash snippet
93
+ that this tool prints to the user (for the rare "pinned install" fallback
94
+ upgrade path) mentions file-I/O primitives by name as plain text. When
95
+ that template literal lived in `dist/index.js` — which also carries the
96
+ outbound HTTP calls — ClawHub's static scanner flagged the pair as
97
+ `potential_exfiltration`. The template now lives in `src/update-commands.ts`
98
+ alongside no network code.
99
+
5
100
  ## 0.11.2 — ClawHub static-scan hygiene
6
101
 
7
102
  Runtime behavior identical to 0.11.1. This release only reshapes file
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;