@coffeexdev/openclaw-sentinel 0.1.0 → 0.1.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/README.md +0 -4
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +26 -0
- package/dist/evaluator.d.ts +4 -0
- package/dist/evaluator.js +94 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +38 -0
- package/dist/limits.d.ts +3 -0
- package/dist/limits.js +27 -0
- package/dist/stateStore.d.ts +5 -0
- package/dist/stateStore.js +35 -0
- package/dist/strategies/base.d.ts +2 -0
- package/dist/strategies/base.js +1 -0
- package/dist/strategies/httpLongPoll.d.ts +2 -0
- package/dist/strategies/httpLongPoll.js +29 -0
- package/dist/strategies/httpPoll.d.ts +2 -0
- package/dist/strategies/httpPoll.js +35 -0
- package/dist/strategies/sse.d.ts +2 -0
- package/dist/strategies/sse.js +41 -0
- package/dist/strategies/websocket.d.ts +2 -0
- package/dist/strategies/websocket.js +35 -0
- package/dist/template.d.ts +1 -0
- package/dist/template.js +26 -0
- package/dist/tool.d.ts +2 -0
- package/dist/tool.js +38 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.js +1 -0
- package/dist/validator.d.ts +2 -0
- package/dist/validator.js +78 -0
- package/dist/watcherManager.d.ts +23 -0
- package/dist/watcherManager.js +185 -0
- package/package.json +8 -2
package/README.md
CHANGED
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createSentinelPlugin } from "./index.js";
|
|
3
|
+
const [cmd, arg] = process.argv.slice(2);
|
|
4
|
+
const plugin = createSentinelPlugin();
|
|
5
|
+
await plugin.init();
|
|
6
|
+
switch (cmd) {
|
|
7
|
+
case "list":
|
|
8
|
+
console.log(JSON.stringify(plugin.manager.list(), null, 2));
|
|
9
|
+
break;
|
|
10
|
+
case "status":
|
|
11
|
+
console.log(JSON.stringify(plugin.manager.status(arg || ""), null, 2));
|
|
12
|
+
break;
|
|
13
|
+
case "enable":
|
|
14
|
+
await plugin.manager.enable(arg || "");
|
|
15
|
+
console.log("ok");
|
|
16
|
+
break;
|
|
17
|
+
case "disable":
|
|
18
|
+
await plugin.manager.disable(arg || "");
|
|
19
|
+
console.log("ok");
|
|
20
|
+
break;
|
|
21
|
+
case "audit":
|
|
22
|
+
console.log(JSON.stringify(await plugin.manager.audit(), null, 2));
|
|
23
|
+
break;
|
|
24
|
+
default:
|
|
25
|
+
console.log("Usage: openclaw-sentinel <list|status <id>|enable <id>|disable <id>|audit>");
|
|
26
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Condition } from "./types.js";
|
|
2
|
+
export declare function evaluateCondition(condition: Condition, payload: unknown, previousPayload: unknown): boolean;
|
|
3
|
+
export declare function evaluateConditions(conditions: Condition[], match: "all" | "any", payload: unknown, previousPayload: unknown): boolean;
|
|
4
|
+
export declare function hashPayload(payload: unknown): string;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
let cachedRegexCtor = null;
|
|
5
|
+
function getSafeRegexCtor() {
|
|
6
|
+
if (cachedRegexCtor)
|
|
7
|
+
return cachedRegexCtor;
|
|
8
|
+
try {
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
10
|
+
const re2Mod = require("re2");
|
|
11
|
+
const RE2Ctor = (re2Mod?.default ?? re2Mod);
|
|
12
|
+
cachedRegexCtor = RE2Ctor;
|
|
13
|
+
return cachedRegexCtor;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// fallback below
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
20
|
+
const re2Wasm = require("re2-wasm");
|
|
21
|
+
const RE2Ctor = (re2Wasm?.RE2 ?? re2Wasm?.default ?? re2Wasm);
|
|
22
|
+
cachedRegexCtor = RE2Ctor;
|
|
23
|
+
return cachedRegexCtor;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error("No safe regex engine available (re2/re2-wasm)");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function getPath(obj, path) {
|
|
30
|
+
return path.split(".").reduce((acc, part) => acc?.[part], obj);
|
|
31
|
+
}
|
|
32
|
+
function safeRegexTest(pattern, input) {
|
|
33
|
+
if (pattern.length > 256)
|
|
34
|
+
throw new Error("Regex pattern too long");
|
|
35
|
+
if (input.length > 4096)
|
|
36
|
+
throw new Error("Regex input too long");
|
|
37
|
+
// basic catastrophic pattern guard
|
|
38
|
+
if (/\([^)]*\|[^)]*\)[+*{]/.test(pattern) ||
|
|
39
|
+
(/\([^)]*\|[^)]*\)/.test(pattern) && /[+*{]/.test(pattern))) {
|
|
40
|
+
throw new Error("Potentially unsafe regex pattern rejected");
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const RE2Ctor = getSafeRegexCtor();
|
|
44
|
+
const flags = "u";
|
|
45
|
+
return new RE2Ctor(pattern, flags).test(input);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
const msg = String(err?.message ?? err);
|
|
49
|
+
if (msg.toLowerCase().includes("safe regex engine"))
|
|
50
|
+
throw err;
|
|
51
|
+
throw new Error("Invalid or unsupported regex pattern");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function evaluateCondition(condition, payload, previousPayload) {
|
|
55
|
+
const current = getPath(payload, condition.path);
|
|
56
|
+
const previous = getPath(previousPayload, condition.path);
|
|
57
|
+
switch (condition.op) {
|
|
58
|
+
case "eq":
|
|
59
|
+
return current === condition.value;
|
|
60
|
+
case "neq":
|
|
61
|
+
return current !== condition.value;
|
|
62
|
+
case "gt":
|
|
63
|
+
return Number(current) > Number(condition.value);
|
|
64
|
+
case "gte":
|
|
65
|
+
return Number(current) >= Number(condition.value);
|
|
66
|
+
case "lt":
|
|
67
|
+
return Number(current) < Number(condition.value);
|
|
68
|
+
case "lte":
|
|
69
|
+
return Number(current) <= Number(condition.value);
|
|
70
|
+
case "exists":
|
|
71
|
+
return current !== undefined && current !== null;
|
|
72
|
+
case "absent":
|
|
73
|
+
return current === undefined || current === null;
|
|
74
|
+
case "contains":
|
|
75
|
+
return typeof current === "string"
|
|
76
|
+
? current.includes(String(condition.value ?? ""))
|
|
77
|
+
: Array.isArray(current)
|
|
78
|
+
? current.includes(condition.value)
|
|
79
|
+
: false;
|
|
80
|
+
case "matches":
|
|
81
|
+
return safeRegexTest(String(condition.value ?? ""), String(current ?? ""));
|
|
82
|
+
case "changed":
|
|
83
|
+
return JSON.stringify(current) !== JSON.stringify(previous);
|
|
84
|
+
default:
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function evaluateConditions(conditions, match, payload, previousPayload) {
|
|
89
|
+
const results = conditions.map((c) => evaluateCondition(c, payload, previousPayload));
|
|
90
|
+
return match === "all" ? results.every(Boolean) : results.some(Boolean);
|
|
91
|
+
}
|
|
92
|
+
export function hashPayload(payload) {
|
|
93
|
+
return crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
94
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { WatcherManager } from "./watcherManager.js";
|
|
2
|
+
import { SentinelConfig } from "./types.js";
|
|
3
|
+
export declare function createSentinelPlugin(overrides?: Partial<SentinelConfig>): {
|
|
4
|
+
manager: WatcherManager;
|
|
5
|
+
init(): Promise<void>;
|
|
6
|
+
register(api: {
|
|
7
|
+
registerTool: (name: string, handler: (input: unknown) => Promise<unknown>) => void;
|
|
8
|
+
}): void;
|
|
9
|
+
};
|
|
10
|
+
export * from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { registerSentinelControl } from "./tool.js";
|
|
2
|
+
import { WatcherManager } from "./watcherManager.js";
|
|
3
|
+
export function createSentinelPlugin(overrides) {
|
|
4
|
+
const config = {
|
|
5
|
+
allowedHosts: ["api.github.com", "api.coingecko.com", "example.com"],
|
|
6
|
+
localDispatchBase: "http://127.0.0.1:18789",
|
|
7
|
+
dispatchAuthToken: process.env.SENTINEL_DISPATCH_TOKEN,
|
|
8
|
+
limits: {
|
|
9
|
+
maxWatchersTotal: 200,
|
|
10
|
+
maxWatchersPerSkill: 20,
|
|
11
|
+
maxConditionsPerWatcher: 25,
|
|
12
|
+
maxIntervalMsFloor: 1000,
|
|
13
|
+
},
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
const manager = new WatcherManager(config, {
|
|
17
|
+
async dispatch(path, body) {
|
|
18
|
+
const headers = { "content-type": "application/json" };
|
|
19
|
+
if (config.dispatchAuthToken)
|
|
20
|
+
headers["authorization"] = `Bearer ${config.dispatchAuthToken}`;
|
|
21
|
+
await fetch(`${config.localDispatchBase}${path}`, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers,
|
|
24
|
+
body: JSON.stringify(body),
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
manager,
|
|
30
|
+
async init() {
|
|
31
|
+
await manager.init();
|
|
32
|
+
},
|
|
33
|
+
register(api) {
|
|
34
|
+
registerSentinelControl(api.registerTool, manager);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export * from "./types.js";
|
package/dist/limits.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { SentinelConfig, WatcherDefinition } from "./types.js";
|
|
2
|
+
export declare function assertWatcherLimits(config: SentinelConfig, watchers: WatcherDefinition[], incoming: WatcherDefinition): void;
|
|
3
|
+
export declare function assertHostAllowed(config: SentinelConfig, endpoint: string): void;
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
function normalizeHost(host) {
|
|
2
|
+
return host.trim().toLowerCase().replace(/\.$/, "");
|
|
3
|
+
}
|
|
4
|
+
export function assertWatcherLimits(config, watchers, incoming) {
|
|
5
|
+
if (watchers.length >= config.limits.maxWatchersTotal) {
|
|
6
|
+
throw new Error(`Watcher limit reached: ${config.limits.maxWatchersTotal}`);
|
|
7
|
+
}
|
|
8
|
+
const perSkill = watchers.filter((w) => w.skillId === incoming.skillId).length;
|
|
9
|
+
if (perSkill >= config.limits.maxWatchersPerSkill) {
|
|
10
|
+
throw new Error(`Per-skill watcher limit reached for ${incoming.skillId}: ${config.limits.maxWatchersPerSkill}`);
|
|
11
|
+
}
|
|
12
|
+
if (incoming.conditions.length > config.limits.maxConditionsPerWatcher) {
|
|
13
|
+
throw new Error(`Too many conditions: ${incoming.conditions.length}`);
|
|
14
|
+
}
|
|
15
|
+
if ((incoming.intervalMs ?? config.limits.maxIntervalMsFloor) < config.limits.maxIntervalMsFloor) {
|
|
16
|
+
throw new Error(`intervalMs too low; minimum is ${config.limits.maxIntervalMsFloor}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function assertHostAllowed(config, endpoint) {
|
|
20
|
+
const parsed = new URL(endpoint);
|
|
21
|
+
const endpointHost = normalizeHost(parsed.hostname);
|
|
22
|
+
const endpointHostWithPort = normalizeHost(parsed.host);
|
|
23
|
+
const allowed = new Set(config.allowedHosts.map(normalizeHost));
|
|
24
|
+
if (!allowed.has(endpointHost) && !allowed.has(endpointHostWithPort)) {
|
|
25
|
+
throw new Error(`Host not allowed: ${parsed.host}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { SentinelStateFile, WatcherDefinition, WatcherRuntimeState } from "./types.js";
|
|
2
|
+
export declare function defaultStatePath(): string;
|
|
3
|
+
export declare function loadState(filePath: string): Promise<SentinelStateFile>;
|
|
4
|
+
export declare function saveState(filePath: string, watchers: WatcherDefinition[], runtime: Record<string, WatcherRuntimeState>): Promise<void>;
|
|
5
|
+
export declare function mergeState(existing: SentinelStateFile, incoming: SentinelStateFile): SentinelStateFile;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
export function defaultStatePath() {
|
|
5
|
+
return path.join(os.homedir(), ".openclaw", "sentinel-state.json");
|
|
6
|
+
}
|
|
7
|
+
export async function loadState(filePath) {
|
|
8
|
+
try {
|
|
9
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
10
|
+
const parsed = JSON.parse(raw);
|
|
11
|
+
return {
|
|
12
|
+
watchers: parsed.watchers ?? [],
|
|
13
|
+
runtime: parsed.runtime ?? {},
|
|
14
|
+
updatedAt: parsed.updatedAt ?? new Date().toISOString(),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return { watchers: [], runtime: {}, updatedAt: new Date().toISOString() };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function saveState(filePath, watchers, runtime) {
|
|
22
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
23
|
+
await fs.writeFile(filePath, JSON.stringify({ watchers, runtime, updatedAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
|
24
|
+
await fs.chmod(filePath, 0o600);
|
|
25
|
+
}
|
|
26
|
+
export function mergeState(existing, incoming) {
|
|
27
|
+
const watcherMap = new Map(existing.watchers.map((w) => [w.id, w]));
|
|
28
|
+
for (const watcher of incoming.watchers)
|
|
29
|
+
watcherMap.set(watcher.id, watcher);
|
|
30
|
+
return {
|
|
31
|
+
watchers: [...watcherMap.values()],
|
|
32
|
+
runtime: { ...existing.runtime, ...incoming.runtime },
|
|
33
|
+
updatedAt: new Date().toISOString(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const httpLongPollStrategy = async (watcher, onPayload, onError) => {
|
|
2
|
+
let active = true;
|
|
3
|
+
const loop = async () => {
|
|
4
|
+
while (active) {
|
|
5
|
+
try {
|
|
6
|
+
const response = await fetch(watcher.endpoint, {
|
|
7
|
+
method: watcher.method ?? "GET",
|
|
8
|
+
headers: watcher.headers,
|
|
9
|
+
body: watcher.body,
|
|
10
|
+
signal: AbortSignal.timeout(watcher.timeoutMs ?? 60000),
|
|
11
|
+
});
|
|
12
|
+
if (!response.ok)
|
|
13
|
+
throw new Error(`http-long-poll non-2xx: ${response.status}`);
|
|
14
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
15
|
+
if (!contentType.toLowerCase().includes("json"))
|
|
16
|
+
throw new Error(`http-long-poll expected JSON, got: ${contentType || "unknown"}`);
|
|
17
|
+
await onPayload(await response.json());
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
await onError(err);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
void loop();
|
|
26
|
+
return async () => {
|
|
27
|
+
active = false;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const httpPollStrategy = async (watcher, onPayload, onError) => {
|
|
2
|
+
const interval = watcher.intervalMs ?? 30000;
|
|
3
|
+
let active = true;
|
|
4
|
+
const tick = async () => {
|
|
5
|
+
if (!active)
|
|
6
|
+
return;
|
|
7
|
+
try {
|
|
8
|
+
const response = await fetch(watcher.endpoint, {
|
|
9
|
+
method: watcher.method ?? "GET",
|
|
10
|
+
headers: watcher.headers,
|
|
11
|
+
body: watcher.body,
|
|
12
|
+
signal: AbortSignal.timeout(watcher.timeoutMs ?? 15000),
|
|
13
|
+
});
|
|
14
|
+
if (!response.ok)
|
|
15
|
+
throw new Error(`http-poll non-2xx: ${response.status}`);
|
|
16
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
17
|
+
if (!contentType.toLowerCase().includes("json"))
|
|
18
|
+
throw new Error(`http-poll expected JSON, got: ${contentType || "unknown"}`);
|
|
19
|
+
const payload = await response.json();
|
|
20
|
+
await onPayload(payload);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
await onError(err);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (active)
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
void tick();
|
|
29
|
+
}, interval);
|
|
30
|
+
};
|
|
31
|
+
void tick();
|
|
32
|
+
return async () => {
|
|
33
|
+
active = false;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const sseStrategy = async (watcher, onPayload, onError) => {
|
|
2
|
+
let active = true;
|
|
3
|
+
const loop = async () => {
|
|
4
|
+
while (active) {
|
|
5
|
+
try {
|
|
6
|
+
const response = await fetch(watcher.endpoint, {
|
|
7
|
+
headers: { Accept: "text/event-stream", ...(watcher.headers ?? {}) },
|
|
8
|
+
signal: AbortSignal.timeout(watcher.timeoutMs ?? 60000),
|
|
9
|
+
});
|
|
10
|
+
if (!response.ok)
|
|
11
|
+
throw new Error(`sse non-2xx: ${response.status}`);
|
|
12
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
13
|
+
if (!contentType.toLowerCase().includes("text/event-stream"))
|
|
14
|
+
throw new Error(`sse expected text/event-stream, got: ${contentType || "unknown"}`);
|
|
15
|
+
const text = await response.text();
|
|
16
|
+
for (const line of text.split("\n")) {
|
|
17
|
+
if (line.startsWith("data:")) {
|
|
18
|
+
const raw = line.slice(5).trim();
|
|
19
|
+
if (!raw)
|
|
20
|
+
continue;
|
|
21
|
+
try {
|
|
22
|
+
await onPayload(JSON.parse(raw));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
await onPayload({ message: raw });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
await new Promise((r) => setTimeout(r, watcher.intervalMs ?? 1000));
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
await onError(err);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
void loop();
|
|
38
|
+
return async () => {
|
|
39
|
+
active = false;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
export const websocketStrategy = async (watcher, onPayload, onError) => {
|
|
3
|
+
let active = true;
|
|
4
|
+
let ws = null;
|
|
5
|
+
const connect = () => {
|
|
6
|
+
ws = new WebSocket(watcher.endpoint, { headers: watcher.headers });
|
|
7
|
+
ws.on("message", async (data) => {
|
|
8
|
+
if (!active)
|
|
9
|
+
return;
|
|
10
|
+
const text = data.toString();
|
|
11
|
+
try {
|
|
12
|
+
await onPayload(JSON.parse(text));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
await onPayload({ message: text });
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
ws.on("error", (err) => {
|
|
19
|
+
if (!active)
|
|
20
|
+
return;
|
|
21
|
+
void onError(err);
|
|
22
|
+
});
|
|
23
|
+
ws.on("close", (code) => {
|
|
24
|
+
if (!active)
|
|
25
|
+
return;
|
|
26
|
+
void onError(new Error(`websocket closed: ${code}`));
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
connect();
|
|
30
|
+
return async () => {
|
|
31
|
+
active = false;
|
|
32
|
+
if (ws && ws.readyState === WebSocket.OPEN)
|
|
33
|
+
ws.close();
|
|
34
|
+
};
|
|
35
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function renderTemplate(template: Record<string, string | number | boolean | null>, context: Record<string, unknown>): Record<string, unknown>;
|
package/dist/template.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const placeholderPattern = /^\$\{(watcher\.(id|skillId)|event\.(name)|payload\.[a-zA-Z0-9_.-]+|timestamp)\}$/;
|
|
2
|
+
function getPath(obj, path) {
|
|
3
|
+
return path.split(".").reduce((acc, part) => acc?.[part], obj);
|
|
4
|
+
}
|
|
5
|
+
export function renderTemplate(template, context) {
|
|
6
|
+
const out = {};
|
|
7
|
+
for (const [key, value] of Object.entries(template)) {
|
|
8
|
+
if (typeof value !== "string") {
|
|
9
|
+
out[key] = value;
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
if (!value.startsWith("${")) {
|
|
13
|
+
out[key] = value;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (!placeholderPattern.test(value)) {
|
|
17
|
+
throw new Error(`Template placeholder not allowed: ${value}`);
|
|
18
|
+
}
|
|
19
|
+
const path = value.slice(2, -1);
|
|
20
|
+
const resolved = getPath(context, path);
|
|
21
|
+
if (resolved === undefined)
|
|
22
|
+
throw new Error(`Template placeholder unresolved: ${value}`);
|
|
23
|
+
out[key] = resolved;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
package/dist/tool.d.ts
ADDED
package/dist/tool.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const inputSchema = z
|
|
3
|
+
.object({
|
|
4
|
+
action: z.enum(["create", "enable", "disable", "remove", "status", "list"]),
|
|
5
|
+
id: z.string().optional(),
|
|
6
|
+
watcher: z.unknown().optional(),
|
|
7
|
+
})
|
|
8
|
+
.strict();
|
|
9
|
+
export function registerSentinelControl(registerTool, manager) {
|
|
10
|
+
registerTool("sentinel_control", async (input) => {
|
|
11
|
+
const parsed = inputSchema.parse(input);
|
|
12
|
+
switch (parsed.action) {
|
|
13
|
+
case "create":
|
|
14
|
+
return manager.create(parsed.watcher);
|
|
15
|
+
case "enable":
|
|
16
|
+
if (!parsed.id)
|
|
17
|
+
throw new Error("id required");
|
|
18
|
+
await manager.enable(parsed.id);
|
|
19
|
+
return { ok: true };
|
|
20
|
+
case "disable":
|
|
21
|
+
if (!parsed.id)
|
|
22
|
+
throw new Error("id required");
|
|
23
|
+
await manager.disable(parsed.id);
|
|
24
|
+
return { ok: true };
|
|
25
|
+
case "remove":
|
|
26
|
+
if (!parsed.id)
|
|
27
|
+
throw new Error("id required");
|
|
28
|
+
await manager.remove(parsed.id);
|
|
29
|
+
return { ok: true };
|
|
30
|
+
case "status":
|
|
31
|
+
if (!parsed.id)
|
|
32
|
+
throw new Error("id required");
|
|
33
|
+
return manager.status(parsed.id);
|
|
34
|
+
case "list":
|
|
35
|
+
return manager.list();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export type Strategy = "http-poll" | "websocket" | "sse" | "http-long-poll";
|
|
2
|
+
export type Operator = "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "exists" | "absent" | "contains" | "matches" | "changed";
|
|
3
|
+
export interface Condition {
|
|
4
|
+
path: string;
|
|
5
|
+
op: Operator;
|
|
6
|
+
value?: unknown;
|
|
7
|
+
}
|
|
8
|
+
export interface FireConfig {
|
|
9
|
+
webhookPath: string;
|
|
10
|
+
eventName: string;
|
|
11
|
+
payloadTemplate: Record<string, string | number | boolean | null>;
|
|
12
|
+
}
|
|
13
|
+
export interface RetryPolicy {
|
|
14
|
+
maxRetries: number;
|
|
15
|
+
baseMs: number;
|
|
16
|
+
maxMs: number;
|
|
17
|
+
}
|
|
18
|
+
export interface WatcherDefinition {
|
|
19
|
+
id: string;
|
|
20
|
+
skillId: string;
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
strategy: Strategy;
|
|
23
|
+
endpoint: string;
|
|
24
|
+
method?: "GET" | "POST";
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
body?: string;
|
|
27
|
+
intervalMs?: number;
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
match: "all" | "any";
|
|
30
|
+
conditions: Condition[];
|
|
31
|
+
fire: FireConfig;
|
|
32
|
+
retry: RetryPolicy;
|
|
33
|
+
fireOnce?: boolean;
|
|
34
|
+
metadata?: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
export interface WatcherRuntimeState {
|
|
37
|
+
id: string;
|
|
38
|
+
lastError?: string;
|
|
39
|
+
lastResponseAt?: string;
|
|
40
|
+
consecutiveFailures: number;
|
|
41
|
+
lastPayloadHash?: string;
|
|
42
|
+
lastPayload?: unknown;
|
|
43
|
+
lastEvaluated?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface SentinelStateFile {
|
|
46
|
+
watchers: WatcherDefinition[];
|
|
47
|
+
runtime: Record<string, WatcherRuntimeState>;
|
|
48
|
+
updatedAt: string;
|
|
49
|
+
}
|
|
50
|
+
export interface SentinelLimits {
|
|
51
|
+
maxWatchersTotal: number;
|
|
52
|
+
maxWatchersPerSkill: number;
|
|
53
|
+
maxConditionsPerWatcher: number;
|
|
54
|
+
maxIntervalMsFloor: number;
|
|
55
|
+
}
|
|
56
|
+
export interface SentinelConfig {
|
|
57
|
+
allowedHosts: string[];
|
|
58
|
+
localDispatchBase: string;
|
|
59
|
+
dispatchAuthToken?: string;
|
|
60
|
+
stateFilePath?: string;
|
|
61
|
+
limits: SentinelLimits;
|
|
62
|
+
}
|
|
63
|
+
export interface GatewayWebhookDispatcher {
|
|
64
|
+
dispatch(path: string, body: Record<string, unknown>): Promise<void>;
|
|
65
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const codeyKeyPattern = /(script|code|eval|handler|function|import|require)/i;
|
|
3
|
+
const codeyValuePattern = /(=>|\bfunction\b|\bimport\s+|\brequire\s*\(|\beval\s*\()/i;
|
|
4
|
+
const conditionSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
path: z.string().min(1),
|
|
7
|
+
op: z.enum([
|
|
8
|
+
"eq",
|
|
9
|
+
"neq",
|
|
10
|
+
"gt",
|
|
11
|
+
"gte",
|
|
12
|
+
"lt",
|
|
13
|
+
"lte",
|
|
14
|
+
"exists",
|
|
15
|
+
"absent",
|
|
16
|
+
"contains",
|
|
17
|
+
"matches",
|
|
18
|
+
"changed",
|
|
19
|
+
]),
|
|
20
|
+
value: z.any().optional(),
|
|
21
|
+
})
|
|
22
|
+
.strict();
|
|
23
|
+
const watcherSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
id: z.string().min(1),
|
|
26
|
+
skillId: z.string().min(1),
|
|
27
|
+
enabled: z.boolean().default(true),
|
|
28
|
+
strategy: z.enum(["http-poll", "websocket", "sse", "http-long-poll"]),
|
|
29
|
+
endpoint: z.string().url(),
|
|
30
|
+
method: z.enum(["GET", "POST"]).optional(),
|
|
31
|
+
headers: z.record(z.string()).optional(),
|
|
32
|
+
body: z.string().optional(),
|
|
33
|
+
intervalMs: z.number().int().positive().optional(),
|
|
34
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
35
|
+
match: z.enum(["all", "any"]),
|
|
36
|
+
conditions: z.array(conditionSchema).min(1),
|
|
37
|
+
fire: z
|
|
38
|
+
.object({
|
|
39
|
+
webhookPath: z.string().regex(/^\//),
|
|
40
|
+
eventName: z.string().min(1),
|
|
41
|
+
payloadTemplate: z.record(z.union([z.string(), z.number(), z.boolean(), z.null()])),
|
|
42
|
+
})
|
|
43
|
+
.strict(),
|
|
44
|
+
retry: z
|
|
45
|
+
.object({
|
|
46
|
+
maxRetries: z.number().int().min(0).max(20),
|
|
47
|
+
baseMs: z.number().int().min(50).max(60000),
|
|
48
|
+
maxMs: z.number().int().min(100).max(300000),
|
|
49
|
+
})
|
|
50
|
+
.strict(),
|
|
51
|
+
fireOnce: z.boolean().optional(),
|
|
52
|
+
metadata: z.record(z.string()).optional(),
|
|
53
|
+
})
|
|
54
|
+
.strict();
|
|
55
|
+
function scanNoCodeLike(input, parentKey = "") {
|
|
56
|
+
if (input === null || input === undefined)
|
|
57
|
+
return;
|
|
58
|
+
if (typeof input === "string") {
|
|
59
|
+
if (codeyValuePattern.test(input))
|
|
60
|
+
throw new Error(`Code-like value rejected at ${parentKey || "<root>"}`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(input)) {
|
|
64
|
+
input.forEach((v, i) => scanNoCodeLike(v, `${parentKey}[${i}]`));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (typeof input === "object") {
|
|
68
|
+
for (const [key, value] of Object.entries(input)) {
|
|
69
|
+
if (codeyKeyPattern.test(key))
|
|
70
|
+
throw new Error(`Code-like field rejected: ${parentKey ? `${parentKey}.` : ""}${key}`);
|
|
71
|
+
scanNoCodeLike(value, parentKey ? `${parentKey}.${key}` : key);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function validateWatcherDefinition(input) {
|
|
76
|
+
scanNoCodeLike(input);
|
|
77
|
+
return watcherSchema.parse(input);
|
|
78
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { GatewayWebhookDispatcher, SentinelConfig, WatcherDefinition, WatcherRuntimeState } from "./types.js";
|
|
2
|
+
export declare class WatcherManager {
|
|
3
|
+
private config;
|
|
4
|
+
private dispatcher;
|
|
5
|
+
private watchers;
|
|
6
|
+
private runtime;
|
|
7
|
+
private stops;
|
|
8
|
+
private retryTimers;
|
|
9
|
+
private statePath;
|
|
10
|
+
constructor(config: SentinelConfig, dispatcher: GatewayWebhookDispatcher);
|
|
11
|
+
init(): Promise<void>;
|
|
12
|
+
create(input: unknown): Promise<WatcherDefinition>;
|
|
13
|
+
list(): WatcherDefinition[];
|
|
14
|
+
status(id: string): WatcherRuntimeState | undefined;
|
|
15
|
+
enable(id: string): Promise<void>;
|
|
16
|
+
disable(id: string): Promise<void>;
|
|
17
|
+
remove(id: string): Promise<void>;
|
|
18
|
+
private require;
|
|
19
|
+
private startWatcher;
|
|
20
|
+
private stopWatcher;
|
|
21
|
+
audit(): Promise<Record<string, unknown>>;
|
|
22
|
+
private persist;
|
|
23
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { evaluateConditions, hashPayload } from "./evaluator.js";
|
|
2
|
+
import { assertHostAllowed, assertWatcherLimits } from "./limits.js";
|
|
3
|
+
import { defaultStatePath, loadState, saveState } from "./stateStore.js";
|
|
4
|
+
import { renderTemplate } from "./template.js";
|
|
5
|
+
import { validateWatcherDefinition } from "./validator.js";
|
|
6
|
+
import { httpPollStrategy } from "./strategies/httpPoll.js";
|
|
7
|
+
import { httpLongPollStrategy } from "./strategies/httpLongPoll.js";
|
|
8
|
+
import { sseStrategy } from "./strategies/sse.js";
|
|
9
|
+
import { websocketStrategy } from "./strategies/websocket.js";
|
|
10
|
+
const backoff = (base, max, failures) => {
|
|
11
|
+
const raw = Math.min(max, base * 2 ** failures);
|
|
12
|
+
const jitter = Math.floor(raw * 0.25 * (Math.random() * 2 - 1));
|
|
13
|
+
return Math.max(base, raw + jitter);
|
|
14
|
+
};
|
|
15
|
+
export class WatcherManager {
|
|
16
|
+
config;
|
|
17
|
+
dispatcher;
|
|
18
|
+
watchers = new Map();
|
|
19
|
+
runtime = {};
|
|
20
|
+
stops = new Map();
|
|
21
|
+
retryTimers = new Map();
|
|
22
|
+
statePath;
|
|
23
|
+
constructor(config, dispatcher) {
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.dispatcher = dispatcher;
|
|
26
|
+
this.statePath = config.stateFilePath ?? defaultStatePath();
|
|
27
|
+
}
|
|
28
|
+
async init() {
|
|
29
|
+
const state = await loadState(this.statePath);
|
|
30
|
+
this.runtime = state.runtime;
|
|
31
|
+
for (const rawWatcher of state.watchers) {
|
|
32
|
+
try {
|
|
33
|
+
const watcher = validateWatcherDefinition(rawWatcher);
|
|
34
|
+
assertHostAllowed(this.config, watcher.endpoint);
|
|
35
|
+
assertWatcherLimits(this.config, this.list(), watcher);
|
|
36
|
+
this.watchers.set(watcher.id, watcher);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
this.runtime[rawWatcher.id] = {
|
|
40
|
+
id: rawWatcher.id,
|
|
41
|
+
consecutiveFailures: (this.runtime[rawWatcher.id]?.consecutiveFailures ?? 0) + 1,
|
|
42
|
+
lastError: `Invalid persisted watcher: ${String(err?.message ?? err)}`,
|
|
43
|
+
lastResponseAt: this.runtime[rawWatcher.id]?.lastResponseAt,
|
|
44
|
+
lastEvaluated: this.runtime[rawWatcher.id]?.lastEvaluated,
|
|
45
|
+
lastPayloadHash: this.runtime[rawWatcher.id]?.lastPayloadHash,
|
|
46
|
+
lastPayload: this.runtime[rawWatcher.id]?.lastPayload,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const watcher of this.list().filter((w) => w.enabled))
|
|
51
|
+
await this.startWatcher(watcher.id);
|
|
52
|
+
}
|
|
53
|
+
async create(input) {
|
|
54
|
+
const watcher = validateWatcherDefinition(input);
|
|
55
|
+
assertHostAllowed(this.config, watcher.endpoint);
|
|
56
|
+
assertWatcherLimits(this.config, this.list(), watcher);
|
|
57
|
+
if (this.watchers.has(watcher.id))
|
|
58
|
+
throw new Error(`Watcher already exists: ${watcher.id}`);
|
|
59
|
+
this.watchers.set(watcher.id, watcher);
|
|
60
|
+
this.runtime[watcher.id] = { id: watcher.id, consecutiveFailures: 0 };
|
|
61
|
+
if (watcher.enabled)
|
|
62
|
+
await this.startWatcher(watcher.id);
|
|
63
|
+
await this.persist();
|
|
64
|
+
return watcher;
|
|
65
|
+
}
|
|
66
|
+
list() {
|
|
67
|
+
return [...this.watchers.values()];
|
|
68
|
+
}
|
|
69
|
+
status(id) {
|
|
70
|
+
return this.runtime[id];
|
|
71
|
+
}
|
|
72
|
+
async enable(id) {
|
|
73
|
+
const w = this.require(id);
|
|
74
|
+
w.enabled = true;
|
|
75
|
+
await this.startWatcher(id);
|
|
76
|
+
await this.persist();
|
|
77
|
+
}
|
|
78
|
+
async disable(id) {
|
|
79
|
+
const w = this.require(id);
|
|
80
|
+
w.enabled = false;
|
|
81
|
+
await this.stopWatcher(id);
|
|
82
|
+
await this.persist();
|
|
83
|
+
}
|
|
84
|
+
async remove(id) {
|
|
85
|
+
await this.stopWatcher(id);
|
|
86
|
+
this.watchers.delete(id);
|
|
87
|
+
delete this.runtime[id];
|
|
88
|
+
await this.persist();
|
|
89
|
+
}
|
|
90
|
+
require(id) {
|
|
91
|
+
const w = this.watchers.get(id);
|
|
92
|
+
if (!w)
|
|
93
|
+
throw new Error(`Watcher not found: ${id}`);
|
|
94
|
+
return w;
|
|
95
|
+
}
|
|
96
|
+
async startWatcher(id) {
|
|
97
|
+
if (this.stops.has(id))
|
|
98
|
+
return;
|
|
99
|
+
const watcher = this.require(id);
|
|
100
|
+
const handler = {
|
|
101
|
+
"http-poll": httpPollStrategy,
|
|
102
|
+
websocket: websocketStrategy,
|
|
103
|
+
sse: sseStrategy,
|
|
104
|
+
"http-long-poll": httpLongPollStrategy,
|
|
105
|
+
}[watcher.strategy];
|
|
106
|
+
const handleFailure = async (err) => {
|
|
107
|
+
const rt = this.runtime[id] ?? { id, consecutiveFailures: 0 };
|
|
108
|
+
rt.consecutiveFailures += 1;
|
|
109
|
+
rt.lastError = String(err?.message ?? err);
|
|
110
|
+
this.runtime[id] = rt;
|
|
111
|
+
if (this.retryTimers.has(id)) {
|
|
112
|
+
await this.persist();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const delay = backoff(watcher.retry.baseMs, watcher.retry.maxMs, rt.consecutiveFailures);
|
|
116
|
+
if (rt.consecutiveFailures <= watcher.retry.maxRetries && watcher.enabled) {
|
|
117
|
+
await this.stopWatcher(id);
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
this.retryTimers.delete(id);
|
|
120
|
+
this.startWatcher(id).catch(() => undefined);
|
|
121
|
+
}, delay);
|
|
122
|
+
this.retryTimers.set(id, timer);
|
|
123
|
+
}
|
|
124
|
+
await this.persist();
|
|
125
|
+
};
|
|
126
|
+
const stop = await handler(watcher, async (payload) => {
|
|
127
|
+
const rt = this.runtime[id] ?? { id, consecutiveFailures: 0 };
|
|
128
|
+
const previousPayload = rt.lastPayload;
|
|
129
|
+
const matched = evaluateConditions(watcher.conditions, watcher.match, payload, previousPayload);
|
|
130
|
+
rt.lastPayloadHash = hashPayload(payload);
|
|
131
|
+
rt.lastPayload = payload;
|
|
132
|
+
rt.lastResponseAt = new Date().toISOString();
|
|
133
|
+
rt.lastEvaluated = rt.lastResponseAt;
|
|
134
|
+
rt.consecutiveFailures = 0;
|
|
135
|
+
rt.lastError = undefined;
|
|
136
|
+
this.runtime[id] = rt;
|
|
137
|
+
if (matched) {
|
|
138
|
+
const body = renderTemplate(watcher.fire.payloadTemplate, {
|
|
139
|
+
watcher,
|
|
140
|
+
event: { name: watcher.fire.eventName },
|
|
141
|
+
payload,
|
|
142
|
+
timestamp: new Date().toISOString(),
|
|
143
|
+
});
|
|
144
|
+
await this.dispatcher.dispatch(watcher.fire.webhookPath, body);
|
|
145
|
+
if (watcher.fireOnce) {
|
|
146
|
+
watcher.enabled = false;
|
|
147
|
+
await this.stopWatcher(id);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
await this.persist();
|
|
151
|
+
}, handleFailure);
|
|
152
|
+
this.stops.set(id, stop);
|
|
153
|
+
}
|
|
154
|
+
async stopWatcher(id) {
|
|
155
|
+
const timer = this.retryTimers.get(id);
|
|
156
|
+
if (timer) {
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
this.retryTimers.delete(id);
|
|
159
|
+
}
|
|
160
|
+
const stop = this.stops.get(id);
|
|
161
|
+
if (stop)
|
|
162
|
+
await Promise.resolve(stop());
|
|
163
|
+
this.stops.delete(id);
|
|
164
|
+
}
|
|
165
|
+
async audit() {
|
|
166
|
+
const bySkill = this.list().reduce((acc, w) => {
|
|
167
|
+
acc[w.skillId] = (acc[w.skillId] ?? 0) + 1;
|
|
168
|
+
return acc;
|
|
169
|
+
}, {});
|
|
170
|
+
return {
|
|
171
|
+
totals: {
|
|
172
|
+
watchers: this.list().length,
|
|
173
|
+
enabled: this.list().filter((w) => w.enabled).length,
|
|
174
|
+
errored: Object.values(this.runtime).filter((r) => !!r.lastError).length,
|
|
175
|
+
},
|
|
176
|
+
bySkill,
|
|
177
|
+
allowedHosts: this.config.allowedHosts,
|
|
178
|
+
limits: this.config.limits,
|
|
179
|
+
statePath: this.statePath,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async persist() {
|
|
183
|
+
await saveState(this.statePath, this.list(), this.runtime);
|
|
184
|
+
}
|
|
185
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffeexdev/openclaw-sentinel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Secure declarative gateway-native watcher plugin for OpenClaw",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openclaw",
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"format:check": "oxfmt --check .",
|
|
37
37
|
"changeset": "changeset",
|
|
38
38
|
"version-packages": "changeset version",
|
|
39
|
-
"release": "changeset publish"
|
|
39
|
+
"release": "changeset publish",
|
|
40
|
+
"prepack": "npm run build"
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
42
43
|
"re2-wasm": "^1.0.2",
|
|
@@ -56,5 +57,10 @@
|
|
|
56
57
|
},
|
|
57
58
|
"engines": {
|
|
58
59
|
"node": ">=20"
|
|
60
|
+
},
|
|
61
|
+
"openclaw": {
|
|
62
|
+
"extensions": [
|
|
63
|
+
"./dist/index.js"
|
|
64
|
+
]
|
|
59
65
|
}
|
|
60
66
|
}
|