openclaw-sentinel 0.3.2 → 2026.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # 🛡️ OpenClaw Sentinel
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/openclaw-sentinel.svg)](https://www.npmjs.com/package/openclaw-sentinel)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)
6
+ [![OpenClaw Plugin](https://img.shields.io/badge/OpenClaw-plugin-blue.svg)](https://github.com/openclaw/openclaw)
7
+ [![Powered by osquery](https://img.shields.io/badge/powered%20by-osquery-00125F.svg)](https://github.com/osquery/osquery)
8
+
9
+ <p align="center">
10
+ <img src="docs/sentinel-alerts-screenshot.png" alt="Sentinel real-time alerts via Signal" width="500">
11
+ </p>
12
+
3
13
  OpenClaw agents run with elevated privileges on your machine — shell access, file operations, network connections. Sentinel continuously monitors for unauthorized access, suspicious processes, privilege escalation, and system anomalies, alerting you in real-time through any OpenClaw channel.
4
14
 
5
15
  A security monitoring plugin for [OpenClaw](https://github.com/openclaw/openclaw), powered by [osquery](https://github.com/osquery/osquery).
@@ -66,7 +76,32 @@ sudo yum install osquery
66
76
 
67
77
  ## Installation
68
78
 
69
- ### From npm (recommended)
79
+ ### Using OpenClaw CLI (recommended)
80
+
81
+ ```bash
82
+ openclaw plugins install openclaw-sentinel
83
+ ```
84
+
85
+ This pulls the package from npm, installs it into `~/.openclaw/extensions/sentinel/`, and registers it in your config automatically.
86
+
87
+ Then configure and restart:
88
+
89
+ ```bash
90
+ openclaw gateway restart
91
+ ```
92
+
93
+ You can also manage it with:
94
+
95
+ ```bash
96
+ openclaw plugins list # See installed plugins
97
+ openclaw plugins info sentinel # Plugin details
98
+ openclaw plugins update # Update all npm-installed plugins
99
+ openclaw plugins uninstall sentinel # Remove
100
+ ```
101
+
102
+ ### From npm (manual)
103
+
104
+ If you prefer manual setup:
70
105
 
71
106
  ```bash
72
107
  npm install -g openclaw-sentinel
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,261 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { writeFileSync, mkdirSync, appendFileSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { AlertTailer } from "../alert-tailer.js";
7
+ const TEST_DIR = join(tmpdir(), `sentinel-alert-tailer-test-${Date.now()}`);
8
+ const EVENTS_PATH = join(TEST_DIR, "events.jsonl");
9
+ function makeEvent(overrides = {}) {
10
+ return {
11
+ id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
12
+ timestamp: Date.now(),
13
+ severity: "high",
14
+ category: "process",
15
+ title: "Suspicious command detected",
16
+ description: "test command",
17
+ hostname: "test-host",
18
+ details: {},
19
+ ...overrides,
20
+ };
21
+ }
22
+ function makeConfig(overrides = {}) {
23
+ return {
24
+ alertSeverity: "info",
25
+ alertChannel: "signal",
26
+ alertTo: "+1234567890",
27
+ clawAssess: false,
28
+ ...overrides,
29
+ };
30
+ }
31
+ describe("AlertTailer", () => {
32
+ beforeEach(() => {
33
+ mkdirSync(TEST_DIR, { recursive: true });
34
+ // Create empty events file
35
+ writeFileSync(EVENTS_PATH, "");
36
+ });
37
+ afterEach(() => {
38
+ rmSync(TEST_DIR, { recursive: true, force: true });
39
+ });
40
+ it("starts at end of file and ignores existing events", async () => {
41
+ // Write some pre-existing events
42
+ const oldEvent = makeEvent({ title: "Old event" });
43
+ appendFileSync(EVENTS_PATH, JSON.stringify(oldEvent) + "\n");
44
+ const alerts = [];
45
+ const tailer = new AlertTailer({
46
+ eventsPath: EVENTS_PATH,
47
+ config: makeConfig(),
48
+ suppressionStore: null,
49
+ sendAlert: async (text) => { alerts.push(text); },
50
+ clawAssessEvent: async () => null,
51
+ });
52
+ await tailer.start();
53
+ // Wait for fs.watch to settle
54
+ await new Promise((r) => setTimeout(r, 300));
55
+ assert.equal(alerts.length, 0, "Should not alert on pre-existing events");
56
+ tailer.stop();
57
+ });
58
+ it("alerts on new events written after start", async () => {
59
+ const alerts = [];
60
+ const tailer = new AlertTailer({
61
+ eventsPath: EVENTS_PATH,
62
+ config: makeConfig(),
63
+ suppressionStore: null,
64
+ sendAlert: async (text) => { alerts.push(text); },
65
+ clawAssessEvent: async () => null,
66
+ });
67
+ await tailer.start();
68
+ await new Promise((r) => setTimeout(r, 200));
69
+ // Write a new event
70
+ const evt = makeEvent({ title: "New suspicious command" });
71
+ appendFileSync(EVENTS_PATH, JSON.stringify(evt) + "\n");
72
+ // Wait for debounce (100ms) + processing
73
+ await new Promise((r) => setTimeout(r, 500));
74
+ assert.ok(alerts.length > 0, "Should alert on new events");
75
+ assert.ok(alerts[0].includes("New suspicious command"));
76
+ tailer.stop();
77
+ });
78
+ it("respects severity threshold", async () => {
79
+ const alerts = [];
80
+ const tailer = new AlertTailer({
81
+ eventsPath: EVENTS_PATH,
82
+ config: makeConfig({ alertSeverity: "high" }),
83
+ suppressionStore: null,
84
+ sendAlert: async (text) => { alerts.push(text); },
85
+ clawAssessEvent: async () => null,
86
+ });
87
+ await tailer.start();
88
+ await new Promise((r) => setTimeout(r, 200));
89
+ // Write info event (below threshold)
90
+ appendFileSync(EVENTS_PATH, JSON.stringify(makeEvent({ severity: "info", title: "Info event" })) + "\n");
91
+ await new Promise((r) => setTimeout(r, 500));
92
+ assert.equal(alerts.length, 0, "Should not alert on info when threshold is high");
93
+ // Write high event (meets threshold)
94
+ appendFileSync(EVENTS_PATH, JSON.stringify(makeEvent({ severity: "high", title: "High event" })) + "\n");
95
+ await new Promise((r) => setTimeout(r, 500));
96
+ assert.equal(alerts.length, 1, "Should alert on high severity");
97
+ tailer.stop();
98
+ });
99
+ it("deduplicates same-title events within window", async () => {
100
+ const alerts = [];
101
+ const tailer = new AlertTailer({
102
+ eventsPath: EVENTS_PATH,
103
+ config: makeConfig(),
104
+ suppressionStore: null,
105
+ sendAlert: async (text) => { alerts.push(text); },
106
+ clawAssessEvent: async () => null,
107
+ });
108
+ await tailer.start();
109
+ await new Promise((r) => setTimeout(r, 200));
110
+ // Write same event twice quickly
111
+ const evt = makeEvent({ title: "Duplicate event", category: "network" });
112
+ appendFileSync(EVENTS_PATH, JSON.stringify(evt) + "\n" + JSON.stringify(evt) + "\n");
113
+ await new Promise((r) => setTimeout(r, 500));
114
+ assert.equal(alerts.length, 1, "Should deduplicate same-title events");
115
+ tailer.stop();
116
+ });
117
+ it("does not deduplicate SSH failed auth events", async () => {
118
+ const alerts = [];
119
+ const tailer = new AlertTailer({
120
+ eventsPath: EVENTS_PATH,
121
+ config: makeConfig(),
122
+ suppressionStore: null,
123
+ sendAlert: async (text) => { alerts.push(text); },
124
+ clawAssessEvent: async () => null,
125
+ });
126
+ await tailer.start();
127
+ await new Promise((r) => setTimeout(r, 200));
128
+ const evt = makeEvent({ title: "SSH failed authentication", category: "ssh_login" });
129
+ appendFileSync(EVENTS_PATH, JSON.stringify(evt) + "\n" + JSON.stringify(evt) + "\n");
130
+ await new Promise((r) => setTimeout(r, 500));
131
+ assert.equal(alerts.length, 2, "SSH failed auth should not be deduped");
132
+ tailer.stop();
133
+ });
134
+ it("includes Claw assessment when enabled", async () => {
135
+ const alerts = [];
136
+ const tailer = new AlertTailer({
137
+ eventsPath: EVENTS_PATH,
138
+ config: makeConfig({ clawAssess: true }),
139
+ suppressionStore: null,
140
+ sendAlert: async (text) => { alerts.push(text); },
141
+ clawAssessEvent: async () => "This is a benign agent command.",
142
+ });
143
+ await tailer.start();
144
+ await new Promise((r) => setTimeout(r, 200));
145
+ appendFileSync(EVENTS_PATH, JSON.stringify(makeEvent()) + "\n");
146
+ await new Promise((r) => setTimeout(r, 500));
147
+ assert.ok(alerts.length > 0);
148
+ assert.ok(alerts[0].includes("benign agent command"), "Should include Claw assessment");
149
+ tailer.stop();
150
+ });
151
+ it("still alerts when Claw assessment fails", async () => {
152
+ const alerts = [];
153
+ const tailer = new AlertTailer({
154
+ eventsPath: EVENTS_PATH,
155
+ config: makeConfig({ clawAssess: true }),
156
+ suppressionStore: null,
157
+ sendAlert: async (text) => { alerts.push(text); },
158
+ clawAssessEvent: async () => { throw new Error("LLM timeout"); },
159
+ });
160
+ await tailer.start();
161
+ await new Promise((r) => setTimeout(r, 200));
162
+ appendFileSync(EVENTS_PATH, JSON.stringify(makeEvent()) + "\n");
163
+ await new Promise((r) => setTimeout(r, 500));
164
+ assert.ok(alerts.length > 0, "Should still alert even if Claw assessment fails");
165
+ tailer.stop();
166
+ });
167
+ it("handles multiple events in a batch", async () => {
168
+ const alerts = [];
169
+ const tailer = new AlertTailer({
170
+ eventsPath: EVENTS_PATH,
171
+ config: makeConfig(),
172
+ suppressionStore: null,
173
+ sendAlert: async (text) => { alerts.push(text); },
174
+ clawAssessEvent: async () => null,
175
+ });
176
+ await tailer.start();
177
+ await new Promise((r) => setTimeout(r, 200));
178
+ // Write 3 different events at once
179
+ const lines = [
180
+ JSON.stringify(makeEvent({ title: "Event A", category: "network" })),
181
+ JSON.stringify(makeEvent({ title: "Event B", category: "file" })),
182
+ JSON.stringify(makeEvent({ title: "Event C", category: "auth" })),
183
+ ].join("\n") + "\n";
184
+ appendFileSync(EVENTS_PATH, lines);
185
+ await new Promise((r) => setTimeout(r, 500));
186
+ assert.equal(alerts.length, 3, "Should process all events in batch");
187
+ tailer.stop();
188
+ });
189
+ it("skips malformed JSON lines", async () => {
190
+ const alerts = [];
191
+ const tailer = new AlertTailer({
192
+ eventsPath: EVENTS_PATH,
193
+ config: makeConfig(),
194
+ suppressionStore: null,
195
+ sendAlert: async (text) => { alerts.push(text); },
196
+ clawAssessEvent: async () => null,
197
+ });
198
+ await tailer.start();
199
+ await new Promise((r) => setTimeout(r, 200));
200
+ appendFileSync(EVENTS_PATH, "not valid json\n" + JSON.stringify(makeEvent({ title: "Valid event" })) + "\n");
201
+ await new Promise((r) => setTimeout(r, 500));
202
+ assert.equal(alerts.length, 1, "Should skip bad lines and process valid ones");
203
+ assert.ok(alerts[0].includes("Valid event"));
204
+ tailer.stop();
205
+ });
206
+ it("stops cleanly", async () => {
207
+ const tailer = new AlertTailer({
208
+ eventsPath: EVENTS_PATH,
209
+ config: makeConfig(),
210
+ suppressionStore: null,
211
+ sendAlert: async () => { },
212
+ clawAssessEvent: async () => null,
213
+ });
214
+ await tailer.start();
215
+ tailer.stop();
216
+ // Write event after stop — should not be processed
217
+ const alerts = [];
218
+ appendFileSync(EVENTS_PATH, JSON.stringify(makeEvent()) + "\n");
219
+ await new Promise((r) => setTimeout(r, 300));
220
+ // No crash, no processing after stop
221
+ assert.ok(true, "Should stop without errors");
222
+ });
223
+ it("handles events file not existing at start", async () => {
224
+ rmSync(EVENTS_PATH, { force: true });
225
+ const tailer = new AlertTailer({
226
+ eventsPath: EVENTS_PATH,
227
+ config: makeConfig(),
228
+ suppressionStore: null,
229
+ sendAlert: async () => { },
230
+ clawAssessEvent: async () => null,
231
+ });
232
+ // Should not throw
233
+ await tailer.start();
234
+ await new Promise((r) => setTimeout(r, 200));
235
+ tailer.stop();
236
+ assert.ok(true, "Should handle missing file gracefully");
237
+ });
238
+ it("updateConfig changes threshold at runtime", async () => {
239
+ const alerts = [];
240
+ const tailer = new AlertTailer({
241
+ eventsPath: EVENTS_PATH,
242
+ config: makeConfig({ alertSeverity: "high" }),
243
+ suppressionStore: null,
244
+ sendAlert: async (text) => { alerts.push(text); },
245
+ clawAssessEvent: async () => null,
246
+ });
247
+ await tailer.start();
248
+ await new Promise((r) => setTimeout(r, 200));
249
+ // Info event — should be filtered
250
+ appendFileSync(EVENTS_PATH, JSON.stringify(makeEvent({ severity: "info", title: "Info 1" })) + "\n");
251
+ await new Promise((r) => setTimeout(r, 500));
252
+ assert.equal(alerts.length, 0);
253
+ // Update config to info threshold
254
+ tailer.updateConfig(makeConfig({ alertSeverity: "info" }));
255
+ // Info event — should now alert
256
+ appendFileSync(EVENTS_PATH, JSON.stringify(makeEvent({ severity: "info", title: "Info 2" })) + "\n");
257
+ await new Promise((r) => setTimeout(r, 500));
258
+ assert.equal(alerts.length, 1);
259
+ tailer.stop();
260
+ });
261
+ });
@@ -131,7 +131,7 @@ describe("analyzeLoginEvents", () => {
131
131
  });
132
132
  it("emits info event for known hosts", () => {
133
133
  const rows = [
134
- { user: "sunil", host: "192.168.1.100", type: "user" },
134
+ { user: "alice", host: "192.168.1.100", type: "user" },
135
135
  ];
136
136
  const events = analyzeLoginEvents(rows, knownHosts);
137
137
  assert.equal(events.length, 1);
@@ -140,9 +140,9 @@ describe("analyzeLoginEvents", () => {
140
140
  });
141
141
  it("emits info event for Tailscale CGNAT range (100.64-127.x.x)", () => {
142
142
  const rows = [
143
- { user: "sunil", host: "100.79.207.74", type: "user" },
144
- { user: "sunil", host: "100.94.48.17", type: "user" },
145
- { user: "sunil", host: "100.127.255.255", type: "user" },
143
+ { user: "alice", host: "100.79.207.74", type: "user" },
144
+ { user: "alice", host: "100.94.48.17", type: "user" },
145
+ { user: "alice", host: "100.127.255.255", type: "user" },
146
146
  ];
147
147
  const events = analyzeLoginEvents(rows, new Set());
148
148
  assert.equal(events.length, 3);
@@ -159,10 +159,10 @@ describe("analyzeLoginEvents", () => {
159
159
  });
160
160
  it("skips localhost and empty hosts", () => {
161
161
  const rows = [
162
- { user: "sunil", host: "", type: "user" },
163
- { user: "sunil", host: "localhost", type: "user" },
164
- { user: "sunil", host: "127.0.0.1", type: "user" },
165
- { user: "sunil", host: "::1", type: "user" },
162
+ { user: "alice", host: "", type: "user" },
163
+ { user: "alice", host: "localhost", type: "user" },
164
+ { user: "alice", host: "127.0.0.1", type: "user" },
165
+ { user: "alice", host: "::1", type: "user" },
166
166
  ];
167
167
  const events = analyzeLoginEvents(rows, new Set());
168
168
  assert.equal(events.length, 0);
@@ -7,11 +7,11 @@ import assert from "node:assert/strict";
7
7
  describe("SSH log line patterns", () => {
8
8
  // These patterns match what sshd outputs and our parser expects
9
9
  it("matches 'Accepted publickey' pattern", () => {
10
- const line = '2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Accepted publickey for sunil from 100.79.207.74 port 52341 ssh2';
10
+ const line = '2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Accepted publickey for alice from 100.79.207.74 port 52341 ssh2';
11
11
  const match = line.match(/sshd.*?:\s+Accepted\s+(\S+)\s+for\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
12
12
  assert.ok(match);
13
13
  assert.equal(match[1], "publickey");
14
- assert.equal(match[2], "sunil");
14
+ assert.equal(match[2], "alice");
15
15
  assert.equal(match[3], "100.79.207.74");
16
16
  assert.equal(match[4], "52341");
17
17
  });
@@ -24,10 +24,10 @@ describe("SSH log line patterns", () => {
24
24
  assert.equal(match[3], "203.0.113.42");
25
25
  });
26
26
  it("matches 'Failed password' pattern", () => {
27
- const line = '2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Failed password for sunil from 203.0.113.42 port 22 ssh2';
27
+ const line = '2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Failed password for alice from 203.0.113.42 port 22 ssh2';
28
28
  const match = line.match(/sshd.*?:\s+Failed\s+password\s+for\s+(?:invalid\s+user\s+)?(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
29
29
  assert.ok(match);
30
- assert.equal(match[1], "sunil");
30
+ assert.equal(match[1], "alice");
31
31
  assert.equal(match[2], "203.0.113.42");
32
32
  });
33
33
  it("matches 'Failed password for invalid user' pattern", () => {
@@ -45,16 +45,16 @@ describe("SSH log line patterns", () => {
45
45
  assert.equal(match[2], "203.0.113.42");
46
46
  });
47
47
  it("matches macOS sshd-session USER_PROCESS pattern", () => {
48
- const line = "Feb 22 16:39:32 sunils-mac-mini sshd-session: sunil [priv][58912]: USER_PROCESS: 58916 ttys001";
48
+ const line = "Feb 22 16:39:32 my-hostname sshd-session: alice [priv][58912]: USER_PROCESS: 58916 ttys001";
49
49
  const match = line.match(/sshd-session:\s+(\S+)\s+\[priv\]\[(\d+)\]:\s+USER_PROCESS:\s+(\d+)\s+(\S+)/);
50
50
  assert.ok(match);
51
- assert.equal(match[1], "sunil");
51
+ assert.equal(match[1], "alice");
52
52
  assert.equal(match[2], "58912");
53
53
  assert.equal(match[3], "58916");
54
54
  assert.equal(match[4], "ttys001");
55
55
  });
56
56
  it("does not match sshd-session DEAD_PROCESS", () => {
57
- const line = "Feb 22 16:38:12 sunils-mac-mini sshd-session: sunil [priv][53930]: DEAD_PROCESS: 53934 ttys012";
57
+ const line = "Feb 22 16:38:12 my-hostname sshd-session: alice [priv][53930]: DEAD_PROCESS: 53934 ttys012";
58
58
  const match = line.match(/sshd-session:\s+(\S+)\s+\[priv\]\[(\d+)\]:\s+USER_PROCESS:\s+(\d+)\s+(\S+)/);
59
59
  assert.equal(match, null);
60
60
  });
@@ -88,27 +88,27 @@ describe("Tailscale IP detection", () => {
88
88
  });
89
89
  describe("Linux sudo log patterns", () => {
90
90
  it("matches sudo command execution", () => {
91
- const line = "Feb 22 16:30:00 myhost sudo[1234]: sunil : TTY=pts/0 ; PWD=/home/sunil ; USER=root ; COMMAND=/usr/bin/apt update";
91
+ const line = "Feb 22 16:30:00 myhost sudo[1234]: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/apt update";
92
92
  const match = line.match(/sudo\[\d+\]:\s+(\S+)\s*:\s*(?:.*?;\s*)?TTY=(\S+)\s*;\s*PWD=(\S+)\s*;\s*USER=(\S+)\s*;\s*COMMAND=(.*)/);
93
93
  assert.ok(match);
94
- assert.equal(match[1], "sunil");
94
+ assert.equal(match[1], "alice");
95
95
  assert.equal(match[2], "pts/0");
96
96
  assert.equal(match[4], "root");
97
97
  assert.equal(match[5].trim(), "/usr/bin/apt update");
98
98
  });
99
99
  it("matches sudo with incorrect password attempts", () => {
100
- const line = "Feb 22 16:30:00 myhost sudo[1234]: sunil : 3 incorrect password attempts ; TTY=pts/0 ; PWD=/home/sunil ; USER=root ; COMMAND=/usr/bin/rm -rf /";
100
+ const line = "Feb 22 16:30:00 myhost sudo[1234]: alice : 3 incorrect password attempts ; TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/rm -rf /";
101
101
  const match = line.match(/sudo\[\d+\]:\s+(\S+)\s*:\s*(?:.*?;\s*)?TTY=(\S+)\s*;\s*PWD=(\S+)\s*;\s*USER=(\S+)\s*;\s*COMMAND=(.*)/);
102
102
  assert.ok(match);
103
- assert.equal(match[1], "sunil");
103
+ assert.equal(match[1], "alice");
104
104
  assert.ok(line.includes("incorrect password"));
105
105
  });
106
106
  it("matches sudo PAM session opened", () => {
107
- const line = "Feb 22 16:30:00 myhost sudo[1234]: pam_unix(sudo:session): session opened for user root(uid=0) by sunil(uid=1000)";
107
+ const line = "Feb 22 16:30:00 myhost sudo[1234]: pam_unix(sudo:session): session opened for user root(uid=0) by alice(uid=1000)";
108
108
  const match = line.match(/sudo\[\d+\]:\s+pam_unix\(sudo:session\):\s+session\s+opened\s+for\s+user\s+(\S+).*?by\s+(\S+)/);
109
109
  assert.ok(match);
110
110
  assert.equal(match[1], "root(uid=0)");
111
- assert.equal(match[2], "sunil(uid=1000)");
111
+ assert.equal(match[2], "alice(uid=1000)");
112
112
  });
113
113
  });
114
114
  describe("Linux user account log patterns", () => {
@@ -125,16 +125,16 @@ describe("Linux user account log patterns", () => {
125
125
  assert.equal(match[1], "olduser");
126
126
  });
127
127
  it("matches passwd password changed", () => {
128
- const line = "Feb 22 16:30:00 myhost passwd[1234]: pam_unix(passwd:chauthtok): password changed for sunil";
128
+ const line = "Feb 22 16:30:00 myhost passwd[1234]: pam_unix(passwd:chauthtok): password changed for alice";
129
129
  const match = line.match(/passwd\[\d+\]:\s+pam_unix\(passwd:chauthtok\):\s+password\s+changed\s+for\s+(\S+)/);
130
130
  assert.ok(match);
131
- assert.equal(match[1], "sunil");
131
+ assert.equal(match[1], "alice");
132
132
  });
133
133
  it("matches usermod change user", () => {
134
- const line = "Feb 22 16:30:00 myhost usermod[1234]: change user 'sunil' shell";
134
+ const line = "Feb 22 16:30:00 myhost usermod[1234]: change user 'alice' shell";
135
135
  const match = line.match(/usermod\[\d+\]:\s+change\s+user\s+'(\S+)'/);
136
136
  assert.ok(match);
137
- assert.equal(match[1], "sunil");
137
+ assert.equal(match[1], "alice");
138
138
  });
139
139
  it("matches groupadd new group (strips trailing comma)", () => {
140
140
  const line = "Feb 22 16:30:00 myhost groupadd[1234]: new group: name=newgroup, GID=1002";
@@ -151,10 +151,10 @@ describe("Linux remote desktop log patterns", () => {
151
151
  assert.equal(match[1], "203.0.113.42");
152
152
  });
153
153
  it("matches xrdp session started", () => {
154
- const line = "Feb 22 16:30:00 myhost xrdp-sesman[1234]: session started for user sunil";
154
+ const line = "Feb 22 16:30:00 myhost xrdp-sesman[1234]: session started for user alice";
155
155
  const match = line.match(/xrdp-sesman\[\d+\]:\s+.*?session\s+started.*?user\s+(\S+)/i);
156
156
  assert.ok(match);
157
- assert.equal(match[1], "sunil");
157
+ assert.equal(match[1], "alice");
158
158
  });
159
159
  it("matches VNC connection", () => {
160
160
  const line = "Feb 22 16:30:00 myhost x11vnc[1234]: Got connection from client 203.0.113.42";
@@ -83,14 +83,14 @@ describe("SuppressionStore", () => {
83
83
  await store.add({
84
84
  scope: "field",
85
85
  field: "user",
86
- fieldValue: "sunil",
86
+ fieldValue: "alice",
87
87
  reason: "Sunil's own sudo",
88
88
  expiresAt: null,
89
89
  });
90
90
  const evt = makeEvent({
91
91
  title: "sudo command executed",
92
92
  category: "privilege",
93
- details: { user: "sunil", command: "/usr/bin/ls" },
93
+ details: { user: "alice", command: "/usr/bin/ls" },
94
94
  });
95
95
  assert.ok(store.isSuppressed(evt));
96
96
  // Different user should not be suppressed
@@ -176,7 +176,7 @@ describe("SuppressionStore", () => {
176
176
  it("describes rules in plain English", () => {
177
177
  assert.equal(SuppressionStore.describe({ scope: "title", title: "sudo command executed" }), 'All alerts titled "sudo command executed"');
178
178
  assert.equal(SuppressionStore.describe({ scope: "category", category: "ssh_login" }), 'All alerts in category "ssh_login"');
179
- assert.equal(SuppressionStore.describe({ scope: "field", field: "user", fieldValue: "sunil" }), 'Alerts where user = "sunil"');
179
+ assert.equal(SuppressionStore.describe({ scope: "field", field: "user", fieldValue: "alice" }), 'Alerts where user = "alice"');
180
180
  assert.equal(SuppressionStore.describe({ scope: "exact", title: "test" }), 'Exact match: "test" with specific description');
181
181
  });
182
182
  it("increments suppress count on repeated matches", async () => {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * AlertTailer — Tails events.jsonl and handles all alerting logic.
3
+ *
4
+ * Decouples detection (log stream watchers, osquery) from alerting
5
+ * (rate limiting, dedup, suppression, Claw assessment, delivery).
6
+ *
7
+ * Architecture:
8
+ * Watchers → logEvent() → events.jsonl → AlertTailer → assess → deliver
9
+ */
10
+ import type { SecurityEvent, SentinelConfig } from "./config.js";
11
+ import { SuppressionStore } from "./suppressions.js";
12
+ export interface AlertTailerOptions {
13
+ eventsPath: string;
14
+ config: SentinelConfig;
15
+ suppressionStore: SuppressionStore | null;
16
+ sendAlert: (text: string) => Promise<void>;
17
+ clawAssessEvent: (evt: SecurityEvent) => Promise<string | null>;
18
+ }
19
+ export declare class AlertTailer {
20
+ private eventsPath;
21
+ private config;
22
+ private suppressionStore;
23
+ private sendAlert;
24
+ private clawAssessEvent;
25
+ private alertState;
26
+ private fileOffset;
27
+ private watcher;
28
+ private running;
29
+ private debounceTimer;
30
+ private pollInterval;
31
+ constructor(opts: AlertTailerOptions);
32
+ start(): Promise<void>;
33
+ stop(): void;
34
+ private debouncedProcessNew;
35
+ private processNewLines;
36
+ private processEvent;
37
+ /** Update config at runtime (e.g., after reload) */
38
+ updateConfig(config: SentinelConfig): void;
39
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * AlertTailer — Tails events.jsonl and handles all alerting logic.
3
+ *
4
+ * Decouples detection (log stream watchers, osquery) from alerting
5
+ * (rate limiting, dedup, suppression, Claw assessment, delivery).
6
+ *
7
+ * Architecture:
8
+ * Watchers → logEvent() → events.jsonl → AlertTailer → assess → deliver
9
+ */
10
+ import { watch } from "node:fs";
11
+ import { stat } from "node:fs/promises";
12
+ import { createReadStream } from "node:fs";
13
+ import { createInterface } from "node:readline";
14
+ import { shouldAlert, meetsThreshold, createAlertState } from "./alerts.js";
15
+ import { SuppressionStore } from "./suppressions.js";
16
+ import { formatAlert } from "./analyzer.js";
17
+ export class AlertTailer {
18
+ eventsPath;
19
+ config;
20
+ suppressionStore;
21
+ sendAlert;
22
+ clawAssessEvent;
23
+ alertState;
24
+ fileOffset = 0;
25
+ watcher = null;
26
+ running = false;
27
+ debounceTimer = null;
28
+ pollInterval = null;
29
+ constructor(opts) {
30
+ this.eventsPath = opts.eventsPath;
31
+ this.config = opts.config;
32
+ this.suppressionStore = opts.suppressionStore;
33
+ this.sendAlert = opts.sendAlert;
34
+ this.clawAssessEvent = opts.clawAssessEvent;
35
+ this.alertState = createAlertState();
36
+ }
37
+ async start() {
38
+ if (this.running)
39
+ return;
40
+ this.running = true;
41
+ // Start at end of file (only process new events)
42
+ try {
43
+ const st = await stat(this.eventsPath);
44
+ this.fileOffset = st.size;
45
+ console.log(`[sentinel] AlertTailer started, offset=${this.fileOffset}`);
46
+ }
47
+ catch {
48
+ this.fileOffset = 0;
49
+ console.log("[sentinel] AlertTailer started, events file not yet created");
50
+ }
51
+ // Watch for changes
52
+ try {
53
+ this.watcher = watch(this.eventsPath, { persistent: false }, (eventType) => {
54
+ if (eventType === "change") {
55
+ this.debouncedProcessNew();
56
+ }
57
+ });
58
+ this.watcher.on("error", (err) => {
59
+ console.warn(`[sentinel] AlertTailer watcher error: ${err.message}`);
60
+ });
61
+ }
62
+ catch {
63
+ // File might not exist yet — poll for it
64
+ this.pollInterval = setInterval(async () => {
65
+ try {
66
+ await stat(this.eventsPath);
67
+ if (this.pollInterval)
68
+ clearInterval(this.pollInterval);
69
+ this.pollInterval = null;
70
+ if (this.running)
71
+ this.start();
72
+ }
73
+ catch { /* keep waiting */ }
74
+ }, 5000);
75
+ }
76
+ }
77
+ stop() {
78
+ this.running = false;
79
+ if (this.watcher) {
80
+ this.watcher.close();
81
+ this.watcher = null;
82
+ }
83
+ if (this.debounceTimer) {
84
+ clearTimeout(this.debounceTimer);
85
+ this.debounceTimer = null;
86
+ }
87
+ if (this.pollInterval) {
88
+ clearInterval(this.pollInterval);
89
+ this.pollInterval = null;
90
+ }
91
+ console.log("[sentinel] AlertTailer stopped");
92
+ }
93
+ debouncedProcessNew() {
94
+ // Debounce: multiple writes within 100ms get batched
95
+ if (this.debounceTimer)
96
+ clearTimeout(this.debounceTimer);
97
+ this.debounceTimer = setTimeout(() => {
98
+ this.processNewLines().catch((err) => {
99
+ console.error(`[sentinel] AlertTailer processing error: ${err.message}`);
100
+ });
101
+ }, 100);
102
+ }
103
+ async processNewLines() {
104
+ if (!this.running)
105
+ return;
106
+ try {
107
+ const st = await stat(this.eventsPath);
108
+ if (st.size <= this.fileOffset)
109
+ return; // No new data
110
+ // Read only new bytes
111
+ const stream = createReadStream(this.eventsPath, {
112
+ start: this.fileOffset,
113
+ encoding: "utf8",
114
+ });
115
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
116
+ const newEvents = [];
117
+ for await (const line of rl) {
118
+ if (!line.trim())
119
+ continue;
120
+ try {
121
+ const evt = JSON.parse(line);
122
+ newEvents.push(evt);
123
+ }
124
+ catch {
125
+ // Skip malformed lines
126
+ }
127
+ }
128
+ this.fileOffset = st.size;
129
+ // Process each event through the alert pipeline
130
+ for (const evt of newEvents) {
131
+ await this.processEvent(evt);
132
+ }
133
+ }
134
+ catch (err) {
135
+ console.error(`[sentinel] AlertTailer read error: ${err.message}`);
136
+ }
137
+ }
138
+ async processEvent(evt) {
139
+ const meets = meetsThreshold(evt.severity, this.config.alertSeverity);
140
+ if (!meets)
141
+ return;
142
+ const should = shouldAlert(evt, this.alertState);
143
+ if (!should)
144
+ return;
145
+ // Check suppression
146
+ const suppressed = this.suppressionStore?.isSuppressed(evt);
147
+ if (suppressed) {
148
+ console.log(`[sentinel] Alert suppressed by rule "${suppressed.reason}" (${SuppressionStore.describe(suppressed)})`);
149
+ return;
150
+ }
151
+ // Claw assessment
152
+ if (this.config.clawAssess) {
153
+ console.log(`[sentinel] Claw assessment enabled, calling for: ${evt.title}`);
154
+ try {
155
+ const assessment = await this.clawAssessEvent(evt);
156
+ console.log(`[sentinel] Claw assessment result: ${assessment?.slice(0, 80) ?? "(null)"}`);
157
+ await this.sendAlert(formatAlert(evt, assessment));
158
+ }
159
+ catch (err) {
160
+ console.warn(`[sentinel] Claw assessment failed: ${err.message ?? err}`);
161
+ await this.sendAlert(formatAlert(evt)).catch(() => { });
162
+ }
163
+ }
164
+ else {
165
+ await this.sendAlert(formatAlert(evt)).catch((err) => {
166
+ console.error(`[sentinel] Alert delivery failed: ${err.message ?? err}`);
167
+ });
168
+ }
169
+ }
170
+ /** Update config at runtime (e.g., after reload) */
171
+ updateConfig(config) {
172
+ this.config = config;
173
+ }
174
+ }
package/dist/config.d.ts CHANGED
@@ -19,8 +19,8 @@ export interface SentinelConfig {
19
19
  trustedPaths?: string[];
20
20
  trustedCommandPatterns?: string[];
21
21
  watchPaths?: string[];
22
- /** Add a one-line LLM assessment to alerts before sending (requires openclaw CLI) */
23
- llmAlertAssessment?: boolean;
22
+ /** Add a one-line Claw assessment to alerts before sending (requires openclaw CLI) */
23
+ clawAssess?: boolean;
24
24
  }
25
25
  export declare const DEFAULT_CONFIG: Required<Pick<SentinelConfig, "pollIntervalMs" | "enableProcessMonitor" | "enableFileIntegrity" | "enableNetworkMonitor" | "trustedSigningIds" | "trustedPaths" | "watchPaths">>;
26
26
  /** A security event detected by Sentinel */
package/dist/index.d.ts CHANGED
@@ -5,10 +5,11 @@
5
5
  * Uses event-driven log tailing for sub-second alerting.
6
6
  *
7
7
  * Architecture:
8
- * osqueryd (daemon, managed externally via launchd) → writes results to JSON log
9
- * Sentinel watcher → tails log file via fs.watch + poll fallback
10
- * Analyzer → evaluates results against detection rules
11
- * OpenClaw messaging alerts on configured channel
8
+ * Watchers (log stream + osquery) → events.jsonl AlertTailer assess → deliver
9
+ *
10
+ * Detection is decoupled from alerting — watchers only parse and persist,
11
+ * while AlertTailer handles rate limiting, dedup, suppression, Claw assessment,
12
+ * and delivery by tailing events.jsonl.
12
13
  *
13
14
  * Note: osqueryd requires root/sudo — it must be started separately
14
15
  * (e.g., via launchd). This plugin only watches the result logs.
package/dist/index.js CHANGED
@@ -5,10 +5,11 @@
5
5
  * Uses event-driven log tailing for sub-second alerting.
6
6
  *
7
7
  * Architecture:
8
- * osqueryd (daemon, managed externally via launchd) → writes results to JSON log
9
- * Sentinel watcher → tails log file via fs.watch + poll fallback
10
- * Analyzer → evaluates results against detection rules
11
- * OpenClaw messaging alerts on configured channel
8
+ * Watchers (log stream + osquery) → events.jsonl AlertTailer assess → deliver
9
+ *
10
+ * Detection is decoupled from alerting — watchers only parse and persist,
11
+ * while AlertTailer handles rate limiting, dedup, suppression, Claw assessment,
12
+ * and delivery by tailing events.jsonl.
12
13
  *
13
14
  * Note: osqueryd requires root/sudo — it must be started separately
14
15
  * (e.g., via launchd). This plugin only watches the result logs.
@@ -19,7 +20,8 @@ import { join } from "node:path";
19
20
  import { homedir } from "node:os";
20
21
  import { Type } from "@sinclair/typebox";
21
22
  import { findOsquery, query } from "./osquery.js";
22
- import { shouldAlert, meetsThreshold, createAlertState } from "./alerts.js";
23
+ import { createAlertState } from "./alerts.js";
24
+ import { AlertTailer } from "./alert-tailer.js";
23
25
  import { EventStore } from "./persistence.js";
24
26
  import { ResultLogWatcher } from "./watcher.js";
25
27
  import { LogStreamWatcher } from "./log-stream.js";
@@ -27,6 +29,12 @@ import { SuppressionStore } from "./suppressions.js";
27
29
  import { execFile } from "node:child_process";
28
30
  import { promisify } from "node:util";
29
31
  const execFileAsync = promisify(execFile);
32
+ // Use globalThis so state survives module re-evaluation on SIGUSR1 restarts.
33
+ const G = globalThis;
34
+ const SENTINEL_NS = "__sentinel__";
35
+ if (!G[SENTINEL_NS])
36
+ G[SENTINEL_NS] = { cleanup: null, initialized: false };
37
+ const _g = G[SENTINEL_NS];
30
38
  import { analyzeProcessEvents, analyzeLoginEvents, analyzeFailedAuth, analyzeListeningPorts, analyzeFileEvents, formatAlert, } from "./analyzer.js";
31
39
  // ── State ──
32
40
  const state = {
@@ -98,7 +106,7 @@ async function checkDaemon(sentinelDir) {
98
106
  * assessment of a security event. Returns the assessment string, or null
99
107
  * on failure.
100
108
  */
101
- async function llmAssessEvent(evt) {
109
+ async function clawAssessEvent(evt) {
102
110
  const details = typeof evt.details === "string" ? evt.details : JSON.stringify(evt.details);
103
111
  const prompt = `You are a security-savvy AI agent named Claw monitoring your human's machine. A security event was detected:
104
112
 
@@ -108,7 +116,7 @@ Category: ${evt.category}
108
116
  Description: ${evt.description}
109
117
  Details: ${details}
110
118
 
111
- Context: This machine runs OpenClaw (an AI assistant platform) which frequently spawns commands via heartbeats, cron jobs, and agent tasks — python one-liners, curl/wget API calls, bq queries, git, npm/node, etc. The user is "sunil".
119
+ Context: This machine runs OpenClaw (an AI assistant platform) which frequently spawns commands via heartbeats, cron jobs, and agent tasks — python one-liners, curl/wget API calls, bq queries, git, npm/node, etc. The user is the machine owner.
112
120
 
113
121
  Reply with ONLY a single short sentence (under 30 words) giving your honest take on whether this is a real problem or likely benign. Be direct, opinionated, and useful — like a senior engineer glancing at an alert. No preamble.`;
114
122
  try {
@@ -134,7 +142,7 @@ Reply with ONLY a single short sentence (under 30 words) giving your honest take
134
142
  }
135
143
  }
136
144
  catch (err) {
137
- console.warn(`[sentinel] LLM assessment failed: ${err?.message ?? err}`);
145
+ console.warn(`[sentinel] Claw assessment failed: ${err?.message ?? err}`);
138
146
  return null;
139
147
  }
140
148
  }
@@ -166,31 +174,7 @@ function handleResult(result, config, sendAlert) {
166
174
  }
167
175
  for (const evt of events) {
168
176
  logEvent(evt);
169
- if (meetsThreshold(evt.severity, config.alertSeverity) && shouldAlert(evt, alertRateState)) {
170
- const suppressed = suppressionStore?.isSuppressed(evt);
171
- if (suppressed) {
172
- console.log(`[sentinel] Alert suppressed by rule "${suppressed.reason}" (${SuppressionStore.describe(suppressed)})`);
173
- }
174
- else if (config.llmAlertAssessment) {
175
- // Get LLM assessment and include it in the alert
176
- console.log(`[sentinel] LLM assessment enabled, calling for: ${evt.title}`);
177
- llmAssessEvent(evt).then((assessment) => {
178
- console.log(`[sentinel] LLM assessment result: ${assessment?.slice(0, 80) ?? "(null)"}`);
179
- sendAlert(formatAlert(evt, assessment)).catch((err) => {
180
- console.error("[sentinel] alert failed:", err);
181
- });
182
- }).catch((err) => {
183
- console.warn(`[sentinel] LLM assessment promise rejected: ${err}`);
184
- sendAlert(formatAlert(evt)).catch(() => { });
185
- });
186
- }
187
- else {
188
- console.log(`[sentinel] LLM assessment NOT enabled (llmAlertAssessment=${config.llmAlertAssessment})`);
189
- sendAlert(formatAlert(evt)).catch((err) => {
190
- console.error("[sentinel] alert failed:", err);
191
- });
192
- }
193
- }
177
+ // AlertTailer handles alerting by tailing events.jsonl
194
178
  }
195
179
  if (events.length > 0) {
196
180
  console.log(`[sentinel] ${events.length} events from ${result.name}`);
@@ -210,9 +194,10 @@ export default function sentinel(api) {
210
194
  }
211
195
  catch { /* ignore */ }
212
196
  const pluginConfig = { ...fileConfig, ...apiConfig };
213
- console.log(`[sentinel] Config v0.3.2: alertSeverity=${pluginConfig.alertSeverity}, alertChannel=${pluginConfig.alertChannel}, llmAssess=${pluginConfig.llmAlertAssessment}, trustedPatterns=${(pluginConfig.trustedCommandPatterns ?? []).length}`);
197
+ console.log(`[sentinel] Config v2026.2.26-1: alertSeverity=${pluginConfig.alertSeverity}, alertChannel=${pluginConfig.alertChannel}, clawAssess=${pluginConfig.clawAssess}, trustedPatterns=${(pluginConfig.trustedCommandPatterns ?? []).length}`);
214
198
  let watcher = null;
215
199
  let logStreamWatcher = null;
200
+ let alertTailer = null;
216
201
  const sentinelDir = pluginConfig.logPath ?? SENTINEL_DIR_DEFAULT;
217
202
  const sendAlert = async (text) => {
218
203
  const channel = pluginConfig.alertChannel;
@@ -373,7 +358,7 @@ export default function sentinel(api) {
373
358
  name: "sentinel_suppress",
374
359
  description: "Manage alert suppression rules. Actions: 'add' (create a suppression), 'list' (show all), 'remove' (delete by id), 'cleanup' (remove expired). " +
375
360
  "Scopes: 'title' (suppress all alerts with this title), 'category' (suppress entire category like ssh_login/privilege/auth), " +
376
- "'field' (suppress when a specific detail field matches, e.g. field=user fieldValue=sunil), 'exact' (suppress only this exact title+description). " +
361
+ "'field' (suppress when a specific detail field matches, e.g. field=user fieldValue=alice), 'exact' (suppress only this exact title+description). " +
377
362
  "Always explain to the user what will be suppressed before adding a rule.",
378
363
  parameters: Type.Object({
379
364
  action: Type.Union([
@@ -482,8 +467,19 @@ export default function sentinel(api) {
482
467
  }
483
468
  },
484
469
  });
470
+ // ── Clean up previous instance if re-initializing (SIGUSR1 restart) ──
471
+ if (typeof _g.cleanup === "function") {
472
+ console.log("[sentinel] Cleaning up previous instance before re-init");
473
+ _g.cleanup();
474
+ _g.cleanup = null;
475
+ _g.initialized = false;
476
+ }
485
477
  // ── Cleanup on process exit ──
486
478
  const cleanup = () => {
479
+ if (alertTailer) {
480
+ alertTailer.stop();
481
+ alertTailer = null;
482
+ }
487
483
  if (watcher) {
488
484
  watcher.stop();
489
485
  watcher = null;
@@ -494,14 +490,16 @@ export default function sentinel(api) {
494
490
  logStreamWatcher = null;
495
491
  }
496
492
  };
493
+ _g.cleanup = cleanup;
497
494
  process.on("exit", cleanup);
498
495
  process.on("SIGTERM", cleanup);
499
496
  process.on("SIGINT", cleanup);
500
497
  // ── Start monitoring immediately (fire-and-forget) ──
501
- if (state.watching) {
498
+ if (_g.initialized) {
502
499
  console.log("[sentinel] Already initialized, skipping double-init");
503
500
  return;
504
501
  }
502
+ _g.initialized = true;
505
503
  (async () => {
506
504
  try {
507
505
  const osqueryiPath = findOsquery(pluginConfig.osqueryPath);
@@ -559,30 +557,20 @@ export default function sentinel(api) {
559
557
  // Start real-time log stream watcher for SSH events
560
558
  logStreamWatcher = new LogStreamWatcher((evt) => {
561
559
  logEvent(evt);
562
- if (meetsThreshold(evt.severity, pluginConfig.alertSeverity) &&
563
- shouldAlert(evt, alertRateState)) {
564
- const suppressed = suppressionStore?.isSuppressed(evt);
565
- if (suppressed) {
566
- console.log(`[sentinel] Alert suppressed by rule "${suppressed.reason}" (${SuppressionStore.describe(suppressed)})`);
567
- }
568
- else if (pluginConfig.llmAlertAssessment) {
569
- llmAssessEvent(evt).then((assessment) => {
570
- sendAlert(formatAlert(evt, assessment)).catch((err) => {
571
- console.error("[sentinel] alert failed:", err);
572
- });
573
- }).catch(() => {
574
- sendAlert(formatAlert(evt)).catch(() => { });
575
- });
576
- }
577
- else {
578
- sendAlert(formatAlert(evt)).catch((err) => {
579
- console.error("[sentinel] alert failed:", err);
580
- });
581
- }
582
- }
560
+ // AlertTailer handles alerting by tailing events.jsonl
583
561
  console.log(`[sentinel] [real-time] ${evt.severity}/${evt.category}: ${evt.title}`);
584
562
  }, state.knownHosts);
585
563
  logStreamWatcher.start();
564
+ // Start AlertTailer — single pipeline for all alerting
565
+ const eventsPath = join(sentinelDir, "events.jsonl");
566
+ alertTailer = new AlertTailer({
567
+ eventsPath,
568
+ config: pluginConfig,
569
+ suppressionStore,
570
+ sendAlert,
571
+ clawAssessEvent,
572
+ });
573
+ await alertTailer.start();
586
574
  }
587
575
  catch (err) {
588
576
  console.error("[sentinel] Failed to start:", err);
@@ -21,8 +21,8 @@ function event(severity, category, title, description, details) {
21
21
  /**
22
22
  * Parse a macOS `log stream` line for SSH events.
23
23
  * Lines look like:
24
- * 2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Accepted publickey for sunil from 100.79.207.74 port 52341 ssh2
25
- * 2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Failed password for sunil from 203.0.113.42 port 22 ssh2
24
+ * 2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Accepted publickey for alice from 100.79.207.74 port 52341 ssh2
25
+ * 2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Failed password for alice from 203.0.113.42 port 22 ssh2
26
26
  * 2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Invalid user admin from 203.0.113.42 port 22
27
27
  */
28
28
  function parseMacOSLogLine(line, knownHosts) {
@@ -51,7 +51,7 @@ function parseMacOSLogLine(line, knownHosts) {
51
51
  /**
52
52
  * Parse a Linux journalctl line for SSH events.
53
53
  * Lines look like:
54
- * Feb 22 16:30:00 hostname sshd[1234]: Accepted publickey for sunil from 100.79.207.74 port 52341 ssh2
54
+ * Feb 22 16:30:00 hostname sshd[1234]: Accepted publickey for alice from 100.79.207.74 port 52341 ssh2
55
55
  */
56
56
  function parseLinuxLogLine(line, knownHosts) {
57
57
  // Same patterns work for both — sshd output is consistent
@@ -60,9 +60,9 @@ function parseLinuxLogLine(line, knownHosts) {
60
60
  /**
61
61
  * Parse Linux sudo events from journalctl / auth.log.
62
62
  * Lines look like:
63
- * Feb 22 16:30:00 hostname sudo[1234]: sunil : TTY=pts/0 ; PWD=/home/sunil ; USER=root ; COMMAND=/usr/bin/apt update
64
- * Feb 22 16:30:00 hostname sudo[1234]: pam_unix(sudo:session): session opened for user root(uid=0) by sunil(uid=1000)
65
- * Feb 22 16:30:00 hostname sudo[1234]: sunil : 3 incorrect password attempts ; TTY=pts/0 ; PWD=/home/sunil ; USER=root ; COMMAND=/usr/bin/rm -rf /
63
+ * Feb 22 16:30:00 hostname sudo[1234]: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/apt update
64
+ * Feb 22 16:30:00 hostname sudo[1234]: pam_unix(sudo:session): session opened for user root(uid=0) by alice(uid=1000)
65
+ * Feb 22 16:30:00 hostname sudo[1234]: alice : 3 incorrect password attempts ; TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/rm -rf /
66
66
  */
67
67
  function parseLinuxSudo(line) {
68
68
  // Standard sudo command log
@@ -85,8 +85,8 @@ function parseLinuxSudo(line) {
85
85
  * Lines from useradd/userdel/usermod/passwd via journalctl or auth.log:
86
86
  * Feb 22 16:30:00 hostname useradd[1234]: new user: name=backdoor, UID=1001, GID=1001, home=/home/backdoor, shell=/bin/bash
87
87
  * Feb 22 16:30:00 hostname userdel[1234]: delete user 'olduser'
88
- * Feb 22 16:30:00 hostname usermod[1234]: change user 'sunil' password
89
- * Feb 22 16:30:00 hostname passwd[1234]: pam_unix(passwd:chauthtok): password changed for sunil
88
+ * Feb 22 16:30:00 hostname usermod[1234]: change user 'alice' password
89
+ * Feb 22 16:30:00 hostname passwd[1234]: pam_unix(passwd:chauthtok): password changed for alice
90
90
  * Feb 22 16:30:00 hostname groupadd[1234]: new group: name=newgroup, GID=1002
91
91
  */
92
92
  function parseLinuxUserAccount(line) {
@@ -121,7 +121,7 @@ function parseLinuxUserAccount(line) {
121
121
  * Parse Linux remote desktop / VNC events.
122
122
  * Lines from xrdp, vncserver, x11vnc:
123
123
  * Feb 22 16:30:00 hostname xrdp[1234]: connected client: 203.0.113.42
124
- * Feb 22 16:30:00 hostname xrdp-sesman[1234]: session started for user sunil
124
+ * Feb 22 16:30:00 hostname xrdp-sesman[1234]: session started for user alice
125
125
  * Feb 22 16:30:00 hostname x11vnc[1234]: Got connection from client 203.0.113.42
126
126
  */
127
127
  function parseLinuxRemoteDesktop(line) {
@@ -145,10 +145,10 @@ function parseLinuxRemoteDesktop(line) {
145
145
  /**
146
146
  * Parse macOS /var/log/system.log lines for SSH events.
147
147
  * Lines look like:
148
- * Feb 22 16:39:32 sunils-mac-mini sshd-session: sunil [priv][58912]: USER_PROCESS: 58916 ttys001
149
- * Feb 22 16:38:12 sunils-mac-mini sshd-session: sunil [priv][53930]: DEAD_PROCESS: 53934 ttys012
150
- * Feb 22 16:51:16 sunils-mac-mini sshd[60738]: Failed password for sunil from 100.79.207.74 port 52341 ssh2
151
- * Feb 22 16:51:16 sunils-mac-mini sshd[60738]: Invalid user admin from 100.79.207.74 port 52341
148
+ * Feb 22 16:39:32 my-hostname sshd-session: alice [priv][58912]: USER_PROCESS: 58916 ttys001
149
+ * Feb 22 16:38:12 my-hostname sshd-session: alice [priv][53930]: DEAD_PROCESS: 53934 ttys012
150
+ * Feb 22 16:51:16 my-hostname sshd[60738]: Failed password for alice from 100.79.207.74 port 52341 ssh2
151
+ * Feb 22 16:51:16 my-hostname sshd[60738]: Invalid user admin from 100.79.207.74 port 52341
152
152
  */
153
153
  function parseMacOSSyslog(line, knownHosts) {
154
154
  // Match sshd-session USER_PROCESS (successful login)
@@ -186,7 +186,7 @@ function parseMacOSSyslog(line, knownHosts) {
186
186
  /**
187
187
  * Parse macOS unified log lines for PAM authentication errors.
188
188
  * Line format:
189
- * 2026-02-22 16:56:51.705020-0500 0x14e5ecb Default 0x0 62761 0 sshd-session: error: PAM: authentication error for sunil from 100.79.207.74
189
+ * 2026-02-22 16:56:51.705020-0500 0x14e5ecb Default 0x0 62761 0 sshd-session: error: PAM: authentication error for alice from 100.79.207.74
190
190
  */
191
191
  function parseMacOSAuthError(line, _knownHosts) {
192
192
  // PAM authentication error (valid user, wrong password)
@@ -5,7 +5,7 @@
5
5
  * - "exact": matches title + description exactly
6
6
  * - "title": matches events with the same title
7
7
  * - "category": matches all events in a category (e.g., all ssh_login)
8
- * - "field": matches a specific field value (e.g., details.user === "sunil")
8
+ * - "field": matches a specific field value (e.g., details.user === "alice")
9
9
  */
10
10
  import type { SecurityEvent } from "./config.js";
11
11
  export interface SuppressionRule {
@@ -5,7 +5,7 @@
5
5
  * - "exact": matches title + description exactly
6
6
  * - "title": matches events with the same title
7
7
  * - "category": matches all events in a category (e.g., all ssh_login)
8
- * - "field": matches a specific field value (e.g., details.user === "sunil")
8
+ * - "field": matches a specific field value (e.g., details.user === "alice")
9
9
  */
10
10
  import { readFile, writeFile, mkdir } from "node:fs/promises";
11
11
  import { existsSync } from "node:fs";
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "id": "sentinel",
3
3
  "name": "Sentinel",
4
- "description": "Real-time endpoint security monitoring for macOS and Linux process execution, SSH connections, privilege escalation, and file integrity alerts via OpenClaw.",
5
- "version": "0.3.2",
6
- "skills": ["skills/sentinel"],
4
+ "description": "Real-time endpoint security monitoring for macOS and Linux \u2014 process execution, SSH connections, privilege escalation, and file integrity alerts via OpenClaw.",
5
+ "version": "2026.3.5",
6
+ "skills": [
7
+ "skills/sentinel"
8
+ ],
7
9
  "configSchema": {
8
10
  "type": "object",
9
11
  "additionalProperties": false,
@@ -26,7 +28,13 @@
26
28
  },
27
29
  "alertSeverity": {
28
30
  "type": "string",
29
- "enum": ["critical", "high", "medium", "low", "info"],
31
+ "enum": [
32
+ "critical",
33
+ "high",
34
+ "medium",
35
+ "low",
36
+ "info"
37
+ ],
30
38
  "description": "Minimum severity level to trigger alerts (default: high)"
31
39
  },
32
40
  "logPath": {
@@ -53,27 +61,35 @@
53
61
  },
54
62
  "trustedSigningIds": {
55
63
  "type": "array",
56
- "items": { "type": "string" },
64
+ "items": {
65
+ "type": "string"
66
+ },
57
67
  "description": "Signing IDs to trust (skip alerts for these)"
58
68
  },
59
69
  "trustedPaths": {
60
70
  "type": "array",
61
- "items": { "type": "string" },
71
+ "items": {
72
+ "type": "string"
73
+ },
62
74
  "description": "Process paths to trust (skip alerts for these)"
63
75
  },
64
76
  "trustedCommandPatterns": {
65
77
  "type": "array",
66
- "items": { "type": "string" },
78
+ "items": {
79
+ "type": "string"
80
+ },
67
81
  "description": "Regex patterns for commands to skip (e.g. OpenClaw heartbeat scripts)"
68
82
  },
69
83
  "watchPaths": {
70
84
  "type": "array",
71
- "items": { "type": "string" },
85
+ "items": {
86
+ "type": "string"
87
+ },
72
88
  "description": "File paths to monitor for integrity changes"
73
89
  },
74
- "llmAlertAssessment": {
90
+ "clawAssess": {
75
91
  "type": "boolean",
76
- "description": "Use LLM to assess suspicious commands before alerting (reduces false positives from automation)"
92
+ "description": "Use Claw to assess suspicious commands before alerting (reduces false positives from automation)"
77
93
  }
78
94
  }
79
95
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-sentinel",
3
- "version": "0.3.2",
3
+ "version": "2026.3.5",
4
4
  "description": "Real-time endpoint security monitoring plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",