@coffeexdev/openclaw-sentinel 0.5.1 → 0.7.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/README.md CHANGED
@@ -59,8 +59,9 @@ Add/update `~/.openclaw/openclaw.json`:
59
59
  // Optional legacy alias for hookSessionPrefix (still supported).
60
60
  // hookSessionKey: "agent:main:hooks:sentinel",
61
61
 
62
- // Optional: bearer token used for dispatch calls back to gateway.
63
- // Set this to your gateway auth token when gateway auth is enabled.
62
+ // Optional: explicit bearer token override for dispatch calls back to gateway.
63
+ // Sentinel auto-detects gateway auth token from runtime config when available,
64
+ // so manual token copy is usually no longer required.
64
65
  // dispatchAuthToken: "<gateway-token>"
65
66
  },
66
67
  },
@@ -89,6 +90,13 @@ your config is using the old root-level shape. Move Sentinel config under:
89
90
 
90
91
  Sentinel also logs a runtime warning when that legacy root key is still observable, but it never writes a root-level `sentinel` key.
91
92
 
93
+ ### Hardening notes (0.6 minor)
94
+
95
+ - `hookSessionKey` remains supported but is deprecated. If both are present, `hookSessionPrefix` now wins.
96
+ - HTTP watcher strategies now set `redirect: "error"` to prevent host-allowlist bypass via redirects.
97
+ - Watcher IDs are now constrained to `^[A-Za-z0-9_-]{1,128}$`.
98
+ - `/hooks/sentinel` validates JSON `Content-Type` when provided and returns `415` for unsupported media types.
99
+
92
100
  ### 4) Create your first watcher (`sentinel_control`)
93
101
 
94
102
  ```json
@@ -146,10 +154,10 @@ Use `sentinel_control`:
146
154
  1. Sentinel evaluates conditions.
147
155
  2. On match, it dispatches a generic callback envelope (`type: "sentinel.callback"`) to `localDispatchBase + webhookPath`.
148
156
  3. The envelope includes stable keys (`intent`, `context`, `watcher`, `trigger`, bounded `payload`, `deliveryTargets`, `deliveryContext`, `source`) so downstream agent behavior is workflow-agnostic.
149
- 4. For `/hooks/sentinel`, Sentinel enqueues an instruction-prefixed system event plus structured JSON envelope with a cron-tagged callback context, then requests an immediate `cron:sentinel-callback` wake (avoids heartbeat-poll prompting).
157
+ 4. For `/hooks/sentinel`, Sentinel enqueues an instruction-prefixed system event with a **structured callback prompt context** (`watcher`, `trigger`, `source`, `deliveryTargets`, `deliveryContext`, `context`, `payload`) plus the full envelope, then requests an immediate `cron:sentinel-callback` wake (avoids heartbeat-poll prompting).
150
158
  5. The hook route creates a **response-delivery contract** keyed by callback dedupe key, preserving original chat/session context (`deliveryContext`) and intended relay targets.
151
159
  6. OpenClaw processes each callback in an isolated hook session: per-watcher by default, or grouped when `hookSessionGroup` / `fire.sessionGroup` is set. Shared global hook-session mode is intentionally not supported.
152
- 7. When hook-session LLM output arrives, Sentinel relays assistant-authored text to the original chat context. If no assistant output arrives before `hookResponseTimeoutMs`, optional fallback relay behavior is applied (`hookResponseFallbackMode`).
160
+ 7. Relay guardrails suppress control-token outputs (`NO_REPLY`, `HEARTBEAT_OK`, empty variants). If model output is unusable, Sentinel emits a concise contextual fallback message. Timeout fallback behavior still follows `hookResponseFallbackMode`.
153
161
 
154
162
  The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent). Response contracts are dedupe-aware by callback dedupe key (`hookResponseDedupeWindowMs`).
155
163
 
@@ -161,7 +169,17 @@ Sample emitted envelope:
161
169
  "version": "1",
162
170
  "intent": "price_threshold_review",
163
171
  "actionable": true,
164
- "watcher": { "id": "eth-price-watch", "skillId": "skills.alerts", "eventName": "eth_target_hit" },
172
+ "watcher": {
173
+ "id": "eth-price-watch",
174
+ "skillId": "skills.alerts",
175
+ "eventName": "eth_target_hit",
176
+ "intent": "price_threshold_review",
177
+ "strategy": "http-poll",
178
+ "endpoint": "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd",
179
+ "match": "all",
180
+ "conditions": [{ "path": "ethereum.usd", "op": "gte", "value": 5000 }],
181
+ "fireOnce": false
182
+ },
165
183
  "trigger": {
166
184
  "matchedAt": "2026-03-04T15:00:00.000Z",
167
185
  "dedupeKey": "<sha256>",
@@ -257,9 +275,16 @@ Global mode options:
257
275
 
258
276
  ```json5
259
277
  {
260
- sentinel: {
261
- allowedHosts: ["api.github.com"],
262
- notificationPayloadMode: "none",
278
+ plugins: {
279
+ entries: {
280
+ "openclaw-sentinel": {
281
+ enabled: true,
282
+ config: {
283
+ allowedHosts: ["api.github.com"],
284
+ notificationPayloadMode: "none",
285
+ },
286
+ },
287
+ },
263
288
  },
264
289
  }
265
290
  ```
@@ -268,9 +293,16 @@ Global mode options:
268
293
 
269
294
  ```json5
270
295
  {
271
- sentinel: {
272
- allowedHosts: ["api.github.com"],
273
- notificationPayloadMode: "concise",
296
+ plugins: {
297
+ entries: {
298
+ "openclaw-sentinel": {
299
+ enabled: true,
300
+ config: {
301
+ allowedHosts: ["api.github.com"],
302
+ notificationPayloadMode: "concise",
303
+ },
304
+ },
305
+ },
274
306
  },
275
307
  }
276
308
  ```
@@ -279,9 +311,16 @@ Global mode options:
279
311
 
280
312
  ```json5
281
313
  {
282
- sentinel: {
283
- allowedHosts: ["api.github.com"],
284
- notificationPayloadMode: "debug",
314
+ plugins: {
315
+ entries: {
316
+ "openclaw-sentinel": {
317
+ enabled: true,
318
+ config: {
319
+ allowedHosts: ["api.github.com"],
320
+ notificationPayloadMode: "debug",
321
+ },
322
+ },
323
+ },
285
324
  },
286
325
  }
287
326
  ```
@@ -334,10 +373,11 @@ Precedence: **watcher override > global setting**.
334
373
  1. Callback is enqueued to isolated hook session.
335
374
  2. Contract captures original delivery context (`deliveryContext` + resolved `deliveryTargets`).
336
375
  3. First assistant-authored `llm_output` for that pending callback is relayed to target chat.
337
- 4. If no assistant output arrives in time (`hookResponseTimeoutMs`), fallback is configurable:
376
+ 4. Reserved control outputs are never relayed (`NO_REPLY`, `HEARTBEAT_OK`, empty variants). If output is unusable, Sentinel sends a concise contextual guardrail fallback.
377
+ 5. If no assistant output arrives in time (`hookResponseTimeoutMs`), timeout fallback is configurable:
338
378
  - `hookResponseFallbackMode: "concise"` (default) sends a short fail-safe relay.
339
- - `hookResponseFallbackMode: "none"` suppresses fallback.
340
- 5. Repeated callbacks with same dedupe key are idempotent within `hookResponseDedupeWindowMs`.
379
+ - `hookResponseFallbackMode: "none"` suppresses timeout fallback.
380
+ 6. Repeated callbacks with same dedupe key are idempotent within `hookResponseDedupeWindowMs`.
341
381
 
342
382
  Example config:
343
383
 
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { SENTINEL_ORIGIN_ACCOUNT_METADATA, SENTINEL_ORIGIN_CHANNEL_METADATA, SENTINEL_ORIGIN_SESSION_KEY_METADATA, SENTINEL_ORIGIN_TARGET_METADATA, } from "./types.js";
3
3
  import { renderTemplate } from "./template.js";
4
+ import { getPath } from "./utils.js";
4
5
  const MAX_PAYLOAD_JSON_CHARS = 4000;
5
6
  function toIntent(eventName) {
6
7
  return (eventName
@@ -57,9 +58,6 @@ function buildDeliveryContextFromMetadata(watcher) {
57
58
  }
58
59
  return Object.keys(context).length > 0 ? context : undefined;
59
60
  }
60
- function getPath(obj, path) {
61
- return path.split(".").reduce((acc, part) => acc?.[part], obj);
62
- }
63
61
  function getTemplateString(value, context) {
64
62
  if (!value)
65
63
  return undefined;
@@ -110,6 +108,12 @@ export function createCallbackEnvelope(args) {
110
108
  id: watcher.id,
111
109
  skillId: watcher.skillId,
112
110
  eventName: watcher.fire.eventName,
111
+ intent,
112
+ strategy: watcher.strategy,
113
+ endpoint: watcher.endpoint,
114
+ match: watcher.match,
115
+ conditions: watcher.conditions,
116
+ fireOnce: watcher.fireOnce ?? false,
113
117
  },
114
118
  trigger: {
115
119
  matchedAt,
@@ -27,6 +27,42 @@ const ConfigSchema = Type.Object({
27
27
  notificationPayloadMode: Type.Optional(NotificationPayloadModeSchema),
28
28
  limits: Type.Optional(LimitsSchema),
29
29
  }, { additionalProperties: false });
30
+ function trimToUndefined(value) {
31
+ if (typeof value !== "string")
32
+ return undefined;
33
+ const trimmed = value.trim();
34
+ return trimmed.length > 0 ? trimmed : undefined;
35
+ }
36
+ function findInvalidNumericPath(input) {
37
+ const numericPaths = [
38
+ "hookRelayDedupeWindowMs",
39
+ "hookResponseTimeoutMs",
40
+ "hookResponseDedupeWindowMs",
41
+ ];
42
+ for (const key of numericPaths) {
43
+ const value = input[key];
44
+ if (typeof value === "number" && !Number.isFinite(value)) {
45
+ return `/${key}`;
46
+ }
47
+ }
48
+ const limits = input.limits;
49
+ if (limits && typeof limits === "object" && !Array.isArray(limits)) {
50
+ const limitsRecord = limits;
51
+ const limitKeys = [
52
+ "maxWatchersTotal",
53
+ "maxWatchersPerSkill",
54
+ "maxConditionsPerWatcher",
55
+ "maxIntervalMsFloor",
56
+ ];
57
+ for (const key of limitKeys) {
58
+ const value = limitsRecord[key];
59
+ if (typeof value === "number" && !Number.isFinite(value)) {
60
+ return `/limits/${key}`;
61
+ }
62
+ }
63
+ }
64
+ return undefined;
65
+ }
30
66
  function withDefaults(value) {
31
67
  const limitsIn = value.limits ?? {};
32
68
  return {
@@ -34,16 +70,23 @@ function withDefaults(value) {
34
70
  localDispatchBase: typeof value.localDispatchBase === "string" && value.localDispatchBase.length > 0
35
71
  ? value.localDispatchBase
36
72
  : "http://127.0.0.1:18789",
37
- dispatchAuthToken: typeof value.dispatchAuthToken === "string" ? value.dispatchAuthToken : undefined,
73
+ dispatchAuthToken: trimToUndefined(value.dispatchAuthToken),
38
74
  hookSessionKey: typeof value.hookSessionKey === "string" ? value.hookSessionKey : undefined,
39
75
  hookSessionPrefix: typeof value.hookSessionPrefix === "string"
40
76
  ? value.hookSessionPrefix
41
77
  : "agent:main:hooks:sentinel",
42
78
  hookSessionGroup: typeof value.hookSessionGroup === "string" ? value.hookSessionGroup : undefined,
43
- hookRelayDedupeWindowMs: typeof value.hookRelayDedupeWindowMs === "number" ? value.hookRelayDedupeWindowMs : 120000,
44
- hookResponseTimeoutMs: typeof value.hookResponseTimeoutMs === "number" ? value.hookResponseTimeoutMs : 30000,
79
+ hookRelayDedupeWindowMs: typeof value.hookRelayDedupeWindowMs === "number" &&
80
+ Number.isFinite(value.hookRelayDedupeWindowMs)
81
+ ? value.hookRelayDedupeWindowMs
82
+ : 120000,
83
+ hookResponseTimeoutMs: typeof value.hookResponseTimeoutMs === "number" &&
84
+ Number.isFinite(value.hookResponseTimeoutMs)
85
+ ? value.hookResponseTimeoutMs
86
+ : 30000,
45
87
  hookResponseFallbackMode: value.hookResponseFallbackMode === "none" ? "none" : "concise",
46
- hookResponseDedupeWindowMs: typeof value.hookResponseDedupeWindowMs === "number"
88
+ hookResponseDedupeWindowMs: typeof value.hookResponseDedupeWindowMs === "number" &&
89
+ Number.isFinite(value.hookResponseDedupeWindowMs)
47
90
  ? value.hookResponseDedupeWindowMs
48
91
  : 120000,
49
92
  stateFilePath: typeof value.stateFilePath === "string" ? value.stateFilePath : undefined,
@@ -53,12 +96,21 @@ function withDefaults(value) {
53
96
  ? "debug"
54
97
  : "concise",
55
98
  limits: {
56
- maxWatchersTotal: typeof limitsIn.maxWatchersTotal === "number" ? limitsIn.maxWatchersTotal : 200,
57
- maxWatchersPerSkill: typeof limitsIn.maxWatchersPerSkill === "number" ? limitsIn.maxWatchersPerSkill : 20,
58
- maxConditionsPerWatcher: typeof limitsIn.maxConditionsPerWatcher === "number"
99
+ maxWatchersTotal: typeof limitsIn.maxWatchersTotal === "number" && Number.isFinite(limitsIn.maxWatchersTotal)
100
+ ? limitsIn.maxWatchersTotal
101
+ : 200,
102
+ maxWatchersPerSkill: typeof limitsIn.maxWatchersPerSkill === "number" &&
103
+ Number.isFinite(limitsIn.maxWatchersPerSkill)
104
+ ? limitsIn.maxWatchersPerSkill
105
+ : 20,
106
+ maxConditionsPerWatcher: typeof limitsIn.maxConditionsPerWatcher === "number" &&
107
+ Number.isFinite(limitsIn.maxConditionsPerWatcher)
59
108
  ? limitsIn.maxConditionsPerWatcher
60
109
  : 25,
61
- maxIntervalMsFloor: typeof limitsIn.maxIntervalMsFloor === "number" ? limitsIn.maxIntervalMsFloor : 1000,
110
+ maxIntervalMsFloor: typeof limitsIn.maxIntervalMsFloor === "number" &&
111
+ Number.isFinite(limitsIn.maxIntervalMsFloor)
112
+ ? limitsIn.maxIntervalMsFloor
113
+ : 1000,
62
114
  },
63
115
  };
64
116
  }
@@ -79,7 +131,17 @@ export const sentinelConfigSchema = {
79
131
  error: { issues: [issue("/", "Config must be an object")] },
80
132
  };
81
133
  }
82
- const candidate = withDefaults(value);
134
+ const source = value;
135
+ const invalidNumericPath = findInvalidNumericPath(source);
136
+ if (invalidNumericPath) {
137
+ return {
138
+ success: false,
139
+ error: {
140
+ issues: [issue(invalidNumericPath, "Expected a finite number")],
141
+ },
142
+ };
143
+ }
144
+ const candidate = withDefaults(source);
83
145
  if (!Value.Check(ConfigSchema, candidate)) {
84
146
  const first = [...Value.Errors(ConfigSchema, candidate)][0];
85
147
  return {
@@ -120,7 +182,7 @@ export const sentinelConfigSchema = {
120
182
  },
121
183
  dispatchAuthToken: {
122
184
  type: "string",
123
- description: "Bearer token for authenticating webhook dispatch requests",
185
+ description: "Optional bearer token override for webhook dispatch auth. Sentinel auto-detects gateway auth token when available.",
124
186
  },
125
187
  hookSessionKey: {
126
188
  type: "string",
@@ -136,13 +198,13 @@ export const sentinelConfigSchema = {
136
198
  description: "Optional default session group key. When set, callbacks without explicit hookSessionGroup are routed to this group session.",
137
199
  },
138
200
  hookRelayDedupeWindowMs: {
139
- type: "number",
201
+ type: "integer",
140
202
  minimum: 0,
141
203
  description: "Suppress duplicate relay messages for the same dedupe key within this window (milliseconds)",
142
204
  default: 120000,
143
205
  },
144
206
  hookResponseTimeoutMs: {
145
- type: "number",
207
+ type: "integer",
146
208
  minimum: 0,
147
209
  description: "Milliseconds to wait for an assistant-authored hook response before optional fallback relay",
148
210
  default: 30000,
@@ -154,7 +216,7 @@ export const sentinelConfigSchema = {
154
216
  default: "concise",
155
217
  },
156
218
  hookResponseDedupeWindowMs: {
157
- type: "number",
219
+ type: "integer",
158
220
  minimum: 0,
159
221
  description: "Deduplicate hook response-delivery contracts by dedupe key within this window (milliseconds)",
160
222
  default: 120000,
@@ -175,22 +237,22 @@ export const sentinelConfigSchema = {
175
237
  description: "Resource limits for watcher creation",
176
238
  properties: {
177
239
  maxWatchersTotal: {
178
- type: "number",
240
+ type: "integer",
179
241
  description: "Maximum total watchers across all skills",
180
242
  default: 200,
181
243
  },
182
244
  maxWatchersPerSkill: {
183
- type: "number",
245
+ type: "integer",
184
246
  description: "Maximum watchers per skill",
185
247
  default: 20,
186
248
  },
187
249
  maxConditionsPerWatcher: {
188
- type: "number",
250
+ type: "integer",
189
251
  description: "Maximum conditions per watcher definition",
190
252
  default: 25,
191
253
  },
192
254
  maxIntervalMsFloor: {
193
- type: "number",
255
+ type: "integer",
194
256
  description: "Minimum allowed polling interval in milliseconds",
195
257
  default: 1000,
196
258
  },
@@ -209,7 +271,7 @@ export const sentinelConfigSchema = {
209
271
  },
210
272
  dispatchAuthToken: {
211
273
  label: "Dispatch Auth Token",
212
- help: "Bearer token for webhook dispatch authentication (or use SENTINEL_DISPATCH_TOKEN env var)",
274
+ help: "Optional override for webhook dispatch auth token. Sentinel auto-detects gateway auth token when available (or use SENTINEL_DISPATCH_TOKEN env var).",
213
275
  sensitive: true,
214
276
  placeholder: "sk-...",
215
277
  },
package/dist/evaluator.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import { createRequire } from "node:module";
3
+ import { getPath } from "./utils.js";
3
4
  const require = createRequire(import.meta.url);
4
5
  let cachedRegexCtor = null;
5
6
  function getSafeRegexCtor() {
@@ -26,9 +27,6 @@ function getSafeRegexCtor() {
26
27
  throw new Error("No safe regex engine available (re2/re2-wasm)");
27
28
  }
28
29
  }
29
- function getPath(obj, path) {
30
- return path.split(".").reduce((acc, part) => acc?.[part], obj);
31
- }
32
30
  function safeRegexTest(pattern, input) {
33
31
  if (pattern.length > 256)
34
32
  throw new Error("Regex pattern too long");