@limits/openclaw 0.0.1 → 0.0.3
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 +36 -0
- package/dist/src/config.js +38 -0
- package/dist/src/configure-wizard.js +117 -0
- package/dist/src/enforcer.js +49 -0
- package/dist/src/index.js +299 -0
- package/dist/src/logger.js +22 -0
- package/dist/src/token.js +36 -0
- package/dist/test/integration/mock-saas.test.js +143 -0
- package/dist/test/unit/failmode.test.js +101 -0
- package/dist/test/unit/post.test.js +79 -0
- package/dist/test/unit/pre.test.js +72 -0
- package/dist/test/unit/token.test.js +48 -0
- package/openclaw.plugin.json +8 -5
- package/package.json +23 -13
- package/scripts/configure.js +0 -97
- package/skills/limits-policy-generator/SKILL.md +0 -110
- package/src/config.ts +0 -70
- package/src/configure-wizard.ts +0 -149
- package/src/enforcer.ts +0 -98
- package/src/index.ts +0 -346
- package/src/logger.ts +0 -32
- package/src/token.ts +0 -38
- package/test/integration/mock-saas.test.ts +0 -163
- package/test/unit/failmode.test.ts +0 -114
- package/test/unit/post.test.ts +0 -88
- package/test/unit/pre.test.ts +0 -78
- package/test/unit/token.test.ts +0 -72
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -15
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
- [Security](#security-openclaw--limits)
|
|
24
24
|
- [Development](#development)
|
|
25
25
|
- [Migration from limits-enforcer](#migration-from-limits-enforcer)
|
|
26
|
+
- [Troubleshooting](#troubleshooting)
|
|
26
27
|
- [License](#license)
|
|
27
28
|
|
|
28
29
|
---
|
|
@@ -46,6 +47,7 @@ Then register the plugin with OpenClaw:
|
|
|
46
47
|
```bash
|
|
47
48
|
openclaw plugins install ./node_modules/@limits/openclaw
|
|
48
49
|
openclaw plugins enable "@limits/openclaw"
|
|
50
|
+
openclaw gateway restart
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
---
|
|
@@ -59,6 +61,12 @@ openclaw plugins enable "@limits/openclaw"
|
|
|
59
61
|
openclaw limits configure
|
|
60
62
|
```
|
|
61
63
|
|
|
64
|
+
If you see **`error: unknown command 'limits'`**, the OpenClaw CLI may not load plugin commands in your environment. Run the configure script directly (from a directory where the package is installed):
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
node node_modules/@limits/openclaw/scripts/configure.js
|
|
68
|
+
```
|
|
69
|
+
|
|
62
70
|
Or set config manually:
|
|
63
71
|
|
|
64
72
|
```json
|
|
@@ -282,6 +290,34 @@ If you were using the plugin under the old name **limits-enforcer**, update your
|
|
|
282
290
|
|
|
283
291
|
After renaming, reinstall the plugin and enable `"@limits/openclaw"`.
|
|
284
292
|
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Troubleshooting
|
|
296
|
+
|
|
297
|
+
- **Config warning "plugin id mismatch (manifest uses '@limits/openclaw', entry hints 'openclaw')"**
|
|
298
|
+
Some OpenClaw gateways install scoped packages under `extensions/openclaw` instead of `extensions/@limits/openclaw`. The gateway then infers the plugin id from the folder name (`openclaw`), which does not match the manifest id `@limits/openclaw`, so the plugin may not load and `openclaw limits configure` shows **unknown command 'limits'**.
|
|
299
|
+
|
|
300
|
+
**Workaround — install under the scoped path so the gateway matches the config entry:**
|
|
301
|
+
```bash
|
|
302
|
+
# Create the scope directory and install the package there (adjust if you use npm -g or another path)
|
|
303
|
+
mkdir -p ~/.openclaw/extensions/@limits
|
|
304
|
+
cp -r ./node_modules/@limits/openclaw ~/.openclaw/extensions/@limits/
|
|
305
|
+
|
|
306
|
+
# Tell OpenClaw to load the plugin from that path
|
|
307
|
+
openclaw plugins install ~/.openclaw/extensions/@limits/openclaw
|
|
308
|
+
openclaw plugins enable "@limits/openclaw"
|
|
309
|
+
openclaw gateway restart
|
|
310
|
+
```
|
|
311
|
+
Then run `openclaw limits configure`.
|
|
312
|
+
|
|
313
|
+
- **`error: unknown command 'limits'`**
|
|
314
|
+
The plugin did not load (often due to the id mismatch above). Use the workaround above, or run the configure wizard without the OpenClaw CLI:
|
|
315
|
+
```bash
|
|
316
|
+
node node_modules/@limits/openclaw/scripts/configure.js
|
|
317
|
+
```
|
|
318
|
+
Or from the plugin repo: `npm run configure`.
|
|
319
|
+
|
|
320
|
+
|
|
285
321
|
---
|
|
286
322
|
|
|
287
323
|
## License
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin config: loaded from gateway config with env var overrides.
|
|
3
|
+
*/
|
|
4
|
+
const STATIC_BASE_URL = "https://extensionally-jettisonable-rosann.ngrok-free.dev";
|
|
5
|
+
const DEFAULTS = {
|
|
6
|
+
baseUrl: STATIC_BASE_URL,
|
|
7
|
+
timeoutMs: 2500,
|
|
8
|
+
failMode: "allow",
|
|
9
|
+
tokenSource: "event.metadata.apiToken",
|
|
10
|
+
redactLogs: true,
|
|
11
|
+
};
|
|
12
|
+
export function loadConfig(api) {
|
|
13
|
+
const raw = api.config?.plugins?.entries?.["@limits/openclaw"]?.config ?? {};
|
|
14
|
+
// baseUrl defaults to static; not in wizard or schema so users don't edit it (config override only for tests/advanced)
|
|
15
|
+
const baseUrl = raw.baseUrl ?? DEFAULTS.baseUrl;
|
|
16
|
+
const timeoutMs = typeof process.env.LIMITS_ENFORCER_TIMEOUT_MS === "string"
|
|
17
|
+
? parseInt(process.env.LIMITS_ENFORCER_TIMEOUT_MS, 10)
|
|
18
|
+
: raw.timeoutMs ?? DEFAULTS.timeoutMs;
|
|
19
|
+
const failMode = process.env.LIMITS_ENFORCER_FAIL_MODE ??
|
|
20
|
+
raw.failMode ??
|
|
21
|
+
DEFAULTS.failMode;
|
|
22
|
+
const tokenSource = process.env.LIMITS_ENFORCER_TOKEN_SOURCE ??
|
|
23
|
+
raw.tokenSource ??
|
|
24
|
+
DEFAULTS.tokenSource;
|
|
25
|
+
const redactLogs = process.env.LIMITS_ENFORCER_REDACT_LOGS !== undefined
|
|
26
|
+
? process.env.LIMITS_ENFORCER_REDACT_LOGS === "true" ||
|
|
27
|
+
process.env.LIMITS_ENFORCER_REDACT_LOGS === "1"
|
|
28
|
+
: raw.redactLogs ?? DEFAULTS.redactLogs;
|
|
29
|
+
const apiToken = process.env.LIMITS_ENFORCER_API_TOKEN ?? raw.apiToken ?? undefined;
|
|
30
|
+
return {
|
|
31
|
+
baseUrl,
|
|
32
|
+
timeoutMs: Number.isNaN(timeoutMs) ? DEFAULTS.timeoutMs : timeoutMs,
|
|
33
|
+
failMode: failMode === "block" ? "block" : "allow",
|
|
34
|
+
tokenSource: typeof tokenSource === "string" ? tokenSource : DEFAULTS.tokenSource,
|
|
35
|
+
redactLogs: Boolean(redactLogs),
|
|
36
|
+
...(typeof apiToken === "string" && apiToken.length > 0 && { apiToken }),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
const CONFIG_PREFIX = 'plugins.entries["@limits/openclaw"].config';
|
|
8
|
+
const SKILL_NAME = "limits-policy-generator";
|
|
9
|
+
function getPluginRoot() {
|
|
10
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
return join(currentDir, "..");
|
|
12
|
+
}
|
|
13
|
+
function getWorkspaceSkillsDest() {
|
|
14
|
+
const workspaceRoot = process.env.OPENCLAW_WORKSPACE || join(os.homedir(), ".openclaw", "workspace");
|
|
15
|
+
return join(workspaceRoot, "skills", SKILL_NAME);
|
|
16
|
+
}
|
|
17
|
+
function copySkillToWorkspace() {
|
|
18
|
+
const pluginRoot = getPluginRoot();
|
|
19
|
+
const source = join(pluginRoot, "skills", SKILL_NAME);
|
|
20
|
+
const dest = getWorkspaceSkillsDest();
|
|
21
|
+
if (!fs.existsSync(source)) {
|
|
22
|
+
console.log("\nSkill source not found, skipping.");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
27
|
+
fs.cpSync(source, dest, { recursive: true });
|
|
28
|
+
console.log(`\nCopied ${SKILL_NAME} to ${dest}.`);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
console.error("\nFailed to copy skill:", err instanceof Error ? err.message : String(err));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function ask(rl, question, defaultValue = "") {
|
|
35
|
+
const prompt = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
rl.question(prompt, (answer) => {
|
|
38
|
+
resolve(typeof answer === "string" && answer.trim() !== "" ? answer.trim() : defaultValue);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function runConfigSet(key, value) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const fullKey = `${CONFIG_PREFIX}.${key}`;
|
|
45
|
+
const child = spawn("openclaw", ["config", "set", fullKey, value], {
|
|
46
|
+
stdio: "inherit",
|
|
47
|
+
shell: true,
|
|
48
|
+
});
|
|
49
|
+
child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`openclaw config set exited ${code}`)));
|
|
50
|
+
child.on("error", reject);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function runConfigGet(fullKey) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const child = spawn("openclaw", ["config", "get", fullKey], {
|
|
56
|
+
stdio: ["inherit", "pipe", "inherit"],
|
|
57
|
+
shell: true,
|
|
58
|
+
});
|
|
59
|
+
let out = "";
|
|
60
|
+
child.stdout?.on("data", (d) => (out += d.toString()));
|
|
61
|
+
child.on("close", () => resolve(out.trim()));
|
|
62
|
+
child.on("error", () => resolve(""));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function runConfigSetFull(fullKey, value) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const child = spawn("openclaw", ["config", "set", fullKey, value], {
|
|
68
|
+
stdio: "inherit",
|
|
69
|
+
shell: true,
|
|
70
|
+
});
|
|
71
|
+
child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`openclaw config set exited ${code}`)));
|
|
72
|
+
child.on("error", reject);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Run the configure wizard (interactive prompts, then openclaw config set).
|
|
77
|
+
* Call this when the user runs `openclaw limits configure` or after link.
|
|
78
|
+
*/
|
|
79
|
+
export async function runConfigureWizard() {
|
|
80
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
81
|
+
console.log("\n🦞 Limits OpenClaw — configure plugin (API token)");
|
|
82
|
+
console.log(" Base URL is fixed. apiToken is used for /openclaw/enforce and policy-generator tools. Run after: openclaw plugins install -l <path>\n");
|
|
83
|
+
const apiToken = await ask(rl, "Organization API key (apiToken) — required for enforce and policy-generator tools", process.env.LIMITS_ENFORCER_API_TOKEN ?? "");
|
|
84
|
+
const sandboxAnswer = await ask(rl, "Do you run agents inside a sandbox?", "N");
|
|
85
|
+
const addSkillAnswer = await ask(rl, "Add limits-policy-generator skill to OpenClaw workspace?", "Y");
|
|
86
|
+
rl.close();
|
|
87
|
+
if (apiToken)
|
|
88
|
+
await runConfigSet("apiToken", JSON.stringify(apiToken));
|
|
89
|
+
const sandboxYes = /^y(es)?$/i.test(sandboxAnswer.trim());
|
|
90
|
+
if (sandboxYes) {
|
|
91
|
+
const SANDBOX_ALLOW_KEY = "tools.sandbox.tools.allow";
|
|
92
|
+
const raw = await runConfigGet(SANDBOX_ALLOW_KEY);
|
|
93
|
+
let allow = [];
|
|
94
|
+
if (raw) {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(raw);
|
|
97
|
+
allow = Array.isArray(parsed) ? parsed : [];
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
allow = [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (allow.includes("@limits/openclaw")) {
|
|
104
|
+
console.log("\n@limits/openclaw is already in tools.sandbox.tools.allow, skipping.");
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
allow.push("@limits/openclaw");
|
|
108
|
+
await runConfigSetFull(SANDBOX_ALLOW_KEY, JSON.stringify(allow));
|
|
109
|
+
console.log("\nAdded @limits/openclaw to tools.sandbox.tools.allow.");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const addSkillYes = /^y(es)?$/i.test(addSkillAnswer.trim());
|
|
113
|
+
if (addSkillYes)
|
|
114
|
+
copySkillToWorkspace();
|
|
115
|
+
console.log("\nDone. Restart the gateway if it is running.");
|
|
116
|
+
console.log('Verify: openclaw config get plugins.entries["@limits/openclaw"]\n');
|
|
117
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SaaS HTTP client: POST /openclaw/enforce with timeout and retries.
|
|
3
|
+
* Never logs request body or apiToken.
|
|
4
|
+
*/
|
|
5
|
+
const RETRY_DELAYS_MS = [200, 400];
|
|
6
|
+
function isRetryableStatus(status) {
|
|
7
|
+
return status === 429 || (status >= 500 && status < 600);
|
|
8
|
+
}
|
|
9
|
+
export async function callEnforce(config, body) {
|
|
10
|
+
const url = `${config.baseUrl.replace(/\/$/, "")}/openclaw/enforce`;
|
|
11
|
+
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
const timeoutId = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(url, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: { "Content-Type": "application/json" },
|
|
18
|
+
body: JSON.stringify(body),
|
|
19
|
+
signal: controller.signal,
|
|
20
|
+
});
|
|
21
|
+
clearTimeout(timeoutId);
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
if (isRetryableStatus(res.status) && attempt < RETRY_DELAYS_MS.length) {
|
|
24
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAYS_MS[attempt] ?? 0));
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const data = (await res.json());
|
|
30
|
+
if (data &&
|
|
31
|
+
typeof data === "object" &&
|
|
32
|
+
"action" in data &&
|
|
33
|
+
typeof data.action === "string") {
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
clearTimeout(timeoutId);
|
|
40
|
+
const isAbort = err.name === "AbortError";
|
|
41
|
+
if (!isAbort && attempt < RETRY_DELAYS_MS.length) {
|
|
42
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAYS_MS[attempt] ?? 0));
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @limits/openclaw: register(api) entrypoint — wires before_tool_call and after_tool_call.
|
|
3
|
+
*/
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { callEnforce } from "./enforcer.js";
|
|
6
|
+
import { log } from "./logger.js";
|
|
7
|
+
import { extractToken } from "./token.js";
|
|
8
|
+
import { runConfigureWizard } from "./configure-wizard.js";
|
|
9
|
+
let firstPreFired = false;
|
|
10
|
+
function getEventAndCtx(args) {
|
|
11
|
+
if (args.length >= 2) {
|
|
12
|
+
return { event: args[0], ctx: args[1] };
|
|
13
|
+
}
|
|
14
|
+
const payload = args[0];
|
|
15
|
+
if (payload && typeof payload === "object" && "event" in payload) {
|
|
16
|
+
const p = payload;
|
|
17
|
+
return { event: p.event, ctx: p.ctx ?? p.context };
|
|
18
|
+
}
|
|
19
|
+
return { event: payload, ctx: undefined };
|
|
20
|
+
}
|
|
21
|
+
function getToolFromEvent(event) {
|
|
22
|
+
if (!event || typeof event !== "object")
|
|
23
|
+
return {};
|
|
24
|
+
const e = event;
|
|
25
|
+
return {
|
|
26
|
+
name: typeof e.toolName === "string" ? e.toolName : undefined,
|
|
27
|
+
args: e.toolParams ?? e.args ?? e.arguments,
|
|
28
|
+
toolCallId: typeof e.toolCallId === "string"
|
|
29
|
+
? e.toolCallId
|
|
30
|
+
: typeof e.id === "string"
|
|
31
|
+
? e.id
|
|
32
|
+
: undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function buildContext(event, ctx) {
|
|
36
|
+
const e = event && typeof event === "object" ? event : {};
|
|
37
|
+
const c = ctx && typeof ctx === "object" ? ctx : {};
|
|
38
|
+
const requestId = (e.requestId ?? c.requestId);
|
|
39
|
+
const runId = (e.runId ?? c.runId);
|
|
40
|
+
const sessionKey = (e.sessionKey ?? c.sessionKey);
|
|
41
|
+
const agentId = (e.agentId ?? c.agentId);
|
|
42
|
+
const channel = (e.channel ?? c.channel);
|
|
43
|
+
const userMessageSummary = (e.userMessageSummary ?? c.userMessageSummary);
|
|
44
|
+
if (requestId === undefined &&
|
|
45
|
+
runId === undefined &&
|
|
46
|
+
sessionKey === undefined &&
|
|
47
|
+
agentId === undefined &&
|
|
48
|
+
channel === undefined &&
|
|
49
|
+
userMessageSummary === undefined) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
...(typeof requestId === "string" && { requestId }),
|
|
54
|
+
...(typeof runId === "string" && { runId }),
|
|
55
|
+
...(typeof sessionKey === "string" && { sessionKey }),
|
|
56
|
+
...(typeof agentId === "string" && { agentId }),
|
|
57
|
+
...(typeof channel === "string" && { channel }),
|
|
58
|
+
...(typeof userMessageSummary === "string" && { userMessageSummary }),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function getResultFromPayload(args) {
|
|
62
|
+
const payload = args[0];
|
|
63
|
+
if (payload && typeof payload === "object" && "result" in payload) {
|
|
64
|
+
return payload.result;
|
|
65
|
+
}
|
|
66
|
+
if (args.length >= 2)
|
|
67
|
+
return args[1];
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
function textResult(text) {
|
|
71
|
+
return { content: [{ type: "text", text }] };
|
|
72
|
+
}
|
|
73
|
+
const POLICY_MODES = ["INSTRUCTIONS", "CONDITIONS", "GUARDRAIL"];
|
|
74
|
+
function registerPolicyTools(api) {
|
|
75
|
+
const config = loadConfig(api);
|
|
76
|
+
const baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
77
|
+
const apiKey = config.apiToken;
|
|
78
|
+
if (!baseUrl || !apiKey || typeof api.registerTool !== "function") {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
api.registerTool({
|
|
82
|
+
name: "limits_generate_create_policy",
|
|
83
|
+
description: "Generate a new policy from natural language and create it on the Limits backend. Use when the user wants to add a new policy (e.g. block payment tools, add a guardrail).",
|
|
84
|
+
parameters: {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
input: {
|
|
88
|
+
type: "string",
|
|
89
|
+
description: "Natural-language description of the policy (what to block, allow, or require).",
|
|
90
|
+
},
|
|
91
|
+
mode: {
|
|
92
|
+
type: "string",
|
|
93
|
+
enum: POLICY_MODES,
|
|
94
|
+
description: "INSTRUCTIONS | CONDITIONS | GUARDRAIL. Default INSTRUCTIONS. Use GUARDRAIL for rules that scan tool output.",
|
|
95
|
+
},
|
|
96
|
+
tools: {
|
|
97
|
+
type: "array",
|
|
98
|
+
items: { type: "string" },
|
|
99
|
+
description: 'Required. Specifies which tool calls this policy applies to. Use ["*"] to apply to all tools / all requests. Use exact tool names (e.g. "read_file") or dot-star prefix patterns (e.g. "stripe_.*") for specific tools. The backend also accepts "stripe_*" and normalizes it to "stripe_.*". Examples: ["*"], ["read_file"], ["stripe_.*", "payment_.*"].',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
required: ["input", "tools"],
|
|
103
|
+
},
|
|
104
|
+
async execute(_id, params) {
|
|
105
|
+
const input = String(params?.input ?? "").trim();
|
|
106
|
+
if (!input)
|
|
107
|
+
return textResult("Error: input is required.");
|
|
108
|
+
const toolsRaw = params?.tools;
|
|
109
|
+
if (!Array.isArray(toolsRaw) ||
|
|
110
|
+
toolsRaw.length === 0 ||
|
|
111
|
+
!toolsRaw.every((t) => typeof t === "string" && t.trim() !== "")) {
|
|
112
|
+
return textResult('Error: tools is required and must be a non-empty array of strings. Use ["*"] for all tools / all requests, or specific tool names / prefix patterns (e.g. ["stripe_.*"]).');
|
|
113
|
+
}
|
|
114
|
+
const tools = toolsRaw;
|
|
115
|
+
const mode = params?.mode && POLICY_MODES.includes(params.mode) ? params.mode : "INSTRUCTIONS";
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetch(`${baseUrl}/api/policies/generatecreate`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: {
|
|
120
|
+
Authorization: `Bearer ${apiKey}`,
|
|
121
|
+
"Content-Type": "application/json",
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify({ input, mode, tools }),
|
|
124
|
+
});
|
|
125
|
+
const body = await res.text();
|
|
126
|
+
if (!res.ok)
|
|
127
|
+
return textResult(`Limits API error (${res.status}): ${body || res.statusText}`);
|
|
128
|
+
return textResult(body || "Policy created.");
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return textResult(`Request failed: ${String(err)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}, { optional: true });
|
|
135
|
+
api.registerTool({
|
|
136
|
+
name: "limits_generate_update_policy",
|
|
137
|
+
description: "Generate updates from natural language and apply them to an existing policy on the Limits backend. Use when the user wants to change an existing policy.",
|
|
138
|
+
parameters: {
|
|
139
|
+
type: "object",
|
|
140
|
+
properties: {
|
|
141
|
+
policyId: {
|
|
142
|
+
type: "string",
|
|
143
|
+
description: "The ID (UUID) of the existing policy to update.",
|
|
144
|
+
},
|
|
145
|
+
input: {
|
|
146
|
+
type: "string",
|
|
147
|
+
description: "Natural-language description of the changes or additions.",
|
|
148
|
+
},
|
|
149
|
+
mode: {
|
|
150
|
+
type: "string",
|
|
151
|
+
enum: POLICY_MODES,
|
|
152
|
+
description: "INSTRUCTIONS | CONDITIONS | GUARDRAIL.",
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
required: ["policyId", "input"],
|
|
156
|
+
},
|
|
157
|
+
async execute(_id, params) {
|
|
158
|
+
const policyId = String(params?.policyId ?? "").trim();
|
|
159
|
+
const input = String(params?.input ?? "").trim();
|
|
160
|
+
if (!policyId || !input)
|
|
161
|
+
return textResult("Error: policyId and input are required.");
|
|
162
|
+
const mode = params?.mode && POLICY_MODES.includes(params.mode) ? params.mode : "INSTRUCTIONS";
|
|
163
|
+
try {
|
|
164
|
+
const res = await fetch(`${baseUrl}/api/policies/${encodeURIComponent(policyId)}/generateupdate`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: {
|
|
167
|
+
Authorization: `Bearer ${apiKey}`,
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
},
|
|
170
|
+
body: JSON.stringify({ input, mode }),
|
|
171
|
+
});
|
|
172
|
+
const body = await res.text();
|
|
173
|
+
if (!res.ok)
|
|
174
|
+
return textResult(`Limits API error (${res.status}): ${body || res.statusText}`);
|
|
175
|
+
return textResult(body || "Policy updated.");
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
return textResult(`Request failed: ${String(err)}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}, { optional: true });
|
|
182
|
+
log("policy-generator tools registered (limits_generate_create_policy, limits_generate_update_policy)");
|
|
183
|
+
}
|
|
184
|
+
export function register(api) {
|
|
185
|
+
log("@limits/openclaw loaded");
|
|
186
|
+
if (typeof api.on !== "function") {
|
|
187
|
+
log("api.on not available, hooks not registered");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
api.on("before_tool_call", async (...args) => {
|
|
191
|
+
if (!firstPreFired) {
|
|
192
|
+
firstPreFired = true;
|
|
193
|
+
log("before_tool_call observed");
|
|
194
|
+
}
|
|
195
|
+
const config = loadConfig(api);
|
|
196
|
+
if (!config.baseUrl) {
|
|
197
|
+
return config.failMode === "block"
|
|
198
|
+
? { block: true, reason: "enforcement unavailable" }
|
|
199
|
+
: null;
|
|
200
|
+
}
|
|
201
|
+
const { event, ctx } = getEventAndCtx(args);
|
|
202
|
+
const apiToken = extractToken(config.tokenSource, event, ctx) ?? config.apiToken;
|
|
203
|
+
const tool = getToolFromEvent(event);
|
|
204
|
+
const context = buildContext(event, ctx);
|
|
205
|
+
const body = {
|
|
206
|
+
phase: "pre",
|
|
207
|
+
apiToken,
|
|
208
|
+
tool: { name: tool.name, args: tool.args, toolCallId: tool.toolCallId },
|
|
209
|
+
...(context && Object.keys(context).length > 0 && { context }),
|
|
210
|
+
};
|
|
211
|
+
let response;
|
|
212
|
+
try {
|
|
213
|
+
response = await callEnforce(config, body);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
response = null;
|
|
217
|
+
}
|
|
218
|
+
if (!response) {
|
|
219
|
+
if (config.failMode === "block") {
|
|
220
|
+
return { block: true, reason: "enforcement unavailable" };
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
if (response.action === "ALLOW")
|
|
225
|
+
return null;
|
|
226
|
+
if (response.action === "BLOCK") {
|
|
227
|
+
return {
|
|
228
|
+
block: true,
|
|
229
|
+
reason: typeof response.reason === "string"
|
|
230
|
+
? response.reason
|
|
231
|
+
: "Blocked by policy",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (response.action === "REWRITE" && "rewriteArgs" in response && response.rewriteArgs !== undefined) {
|
|
235
|
+
return { args: response.rewriteArgs };
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
});
|
|
239
|
+
api.on("after_tool_call", async (...args) => {
|
|
240
|
+
const config = loadConfig(api);
|
|
241
|
+
if (!config.baseUrl) {
|
|
242
|
+
if (config.failMode === "block") {
|
|
243
|
+
return { result: "[BLOCKED by policy — content withheld]" };
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
const { event, ctx } = getEventAndCtx(args);
|
|
248
|
+
const apiToken = extractToken(config.tokenSource, event, ctx) ?? config.apiToken;
|
|
249
|
+
const tool = getToolFromEvent(event);
|
|
250
|
+
const result = getResultFromPayload(args);
|
|
251
|
+
const context = buildContext(event, ctx);
|
|
252
|
+
const body = {
|
|
253
|
+
phase: "post",
|
|
254
|
+
apiToken,
|
|
255
|
+
tool: {
|
|
256
|
+
name: tool.name,
|
|
257
|
+
args: tool.args,
|
|
258
|
+
toolCallId: tool.toolCallId,
|
|
259
|
+
result,
|
|
260
|
+
},
|
|
261
|
+
...(context && Object.keys(context).length > 0 && { context }),
|
|
262
|
+
};
|
|
263
|
+
let response;
|
|
264
|
+
try {
|
|
265
|
+
response = await callEnforce(config, body);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
response = null;
|
|
269
|
+
}
|
|
270
|
+
if (!response) {
|
|
271
|
+
if (config.failMode === "block") {
|
|
272
|
+
return { result: "[BLOCKED by policy — content withheld]" };
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
if (response.action === "ALLOW")
|
|
277
|
+
return null;
|
|
278
|
+
if (response.action === "BLOCK") {
|
|
279
|
+
return { result: "[BLOCKED by policy — content withheld]" };
|
|
280
|
+
}
|
|
281
|
+
if (response.action === "REDACT" && response.redactedResult !== undefined) {
|
|
282
|
+
return { result: response.redactedResult };
|
|
283
|
+
}
|
|
284
|
+
if (response.action === "REWRITE" && response.rewrittenResult !== undefined) {
|
|
285
|
+
return { result: response.rewrittenResult };
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
});
|
|
289
|
+
registerPolicyTools(api);
|
|
290
|
+
if (typeof api.registerCli === "function") {
|
|
291
|
+
api.registerCli((opts) => {
|
|
292
|
+
const program = opts.program;
|
|
293
|
+
const root = program.command("limits").description("Limits OpenClaw plugin");
|
|
294
|
+
root.command("configure").description("Configure API token and base URL (wizard)").action(async () => {
|
|
295
|
+
await runConfigureWizard();
|
|
296
|
+
});
|
|
297
|
+
}, { commands: ["limits"] });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe logger for @limits/openclaw. Never logs apiToken, request body, or tool args.
|
|
3
|
+
*/
|
|
4
|
+
const PREFIX = "[@limits/openclaw]";
|
|
5
|
+
export function log(message, ...safeArgs) {
|
|
6
|
+
const safe = safeArgs.map((a) => typeof a === "string" || typeof a === "number" || typeof a === "boolean"
|
|
7
|
+
? a
|
|
8
|
+
: "[object]");
|
|
9
|
+
console.log(PREFIX, message, ...safe);
|
|
10
|
+
}
|
|
11
|
+
export function warn(message, ...safeArgs) {
|
|
12
|
+
const safe = safeArgs.map((a) => typeof a === "string" || typeof a === "number" || typeof a === "boolean"
|
|
13
|
+
? a
|
|
14
|
+
: "[object]");
|
|
15
|
+
console.warn(PREFIX, message, ...safe);
|
|
16
|
+
}
|
|
17
|
+
export function error(message, ...safeArgs) {
|
|
18
|
+
const safe = safeArgs.map((a) => typeof a === "string" || typeof a === "number" || typeof a === "boolean"
|
|
19
|
+
? a
|
|
20
|
+
: "[object]");
|
|
21
|
+
console.error(PREFIX, message, ...safe);
|
|
22
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract API token from a dot-separated path (event, ctx, or env).
|
|
3
|
+
* Never throws; returns undefined if path is missing or invalid.
|
|
4
|
+
*/
|
|
5
|
+
export function extractToken(tokenSource, event, ctx) {
|
|
6
|
+
if (typeof tokenSource !== "string" || !tokenSource.trim()) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
const path = tokenSource.trim().split(".");
|
|
10
|
+
if (path.length < 2) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
const [root, ...rest] = path;
|
|
14
|
+
let obj;
|
|
15
|
+
if (root === "event") {
|
|
16
|
+
obj = event;
|
|
17
|
+
}
|
|
18
|
+
else if (root === "ctx") {
|
|
19
|
+
obj = ctx;
|
|
20
|
+
}
|
|
21
|
+
else if (root === "env") {
|
|
22
|
+
const key = rest.join(".");
|
|
23
|
+
const val = process.env[key];
|
|
24
|
+
return typeof val === "string" ? val : undefined;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
for (const key of rest) {
|
|
30
|
+
if (obj === null || obj === undefined || typeof obj !== "object") {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
obj = obj[key];
|
|
34
|
+
}
|
|
35
|
+
return typeof obj === "string" ? obj : undefined;
|
|
36
|
+
}
|