@oked/openclaw 0.1.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/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/classify-openclaw.d.ts +6 -0
- package/dist/classify-openclaw.js +84 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.js +252 -0
- package/openclaw.plugin.json +73 -0
- package/package.json +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OKed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# @oked/openclaw
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@oked/openclaw)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](./dist/index.d.ts)
|
|
6
|
+
|
|
7
|
+
Zero-code integration for OpenClaw. Registers a `before_tool_call` hook that fires for **every** tool the OpenClaw agent calls (built-in or skill-registered), classifies it, and freezes the agent on dangerous actions until you approve from the OKed mobile app.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
OpenClaw's built-in iOS approvals cover **shell commands** (`exec.approval.*`). They do **not** automatically cover the skill / plugin tools agents actually use to touch the real world (sending iMessages, making phone calls, deploying sites, charging cards). Skill authors have to opt in to `plugin.approval.request`, and most don't. This plugin closes that gap.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @oked/openclaw @oked/openclaw-cli
|
|
17
|
+
oked-openclaw init
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The CLI ([`@oked/openclaw-cli`](../openclaw-cli)) runs `openclaw plugins install --link` against this package, prompts for your API key + `minTier`, writes the entry into `~/.openclaw/openclaw.json`, and restarts the OpenClaw daemon.
|
|
21
|
+
|
|
22
|
+
If you'd rather do it by hand:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
openclaw plugins install --link <path-to-this-package>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then in `~/.openclaw/openclaw.json`:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"plugins": {
|
|
33
|
+
"allow": ["oked"],
|
|
34
|
+
"entries": {
|
|
35
|
+
"oked": {
|
|
36
|
+
"enabled": true,
|
|
37
|
+
"apiKey": "ok_...",
|
|
38
|
+
"backendUrl": "https://api.oked.ai",
|
|
39
|
+
"minTier": "review"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Restart the OpenClaw gateway and you're done.
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
All keys go under `plugins.entries.oked`:
|
|
51
|
+
|
|
52
|
+
| Key | Type | Default | Description |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| `apiKey` | `string` | `OKED_API_KEY` env | Your OKed API key. Required. |
|
|
55
|
+
| `backendUrl` | `string` | `https://api.oked.ai` | Override the OKed backend URL. |
|
|
56
|
+
| `minTier` | `"review" \| "high_stakes"` | `"review"` | Minimum risk tier that triggers approval. Set to `"high_stakes"` to only gate the most dangerous calls. `warning` is informational only and does not block. |
|
|
57
|
+
| `alwaysApprove` | `string[]` | `[]` | Tool names to always require approval for, regardless of classifier. |
|
|
58
|
+
| `alwaysAllow` | `string[]` | `[]` | Tool names to never gate. Use sparingly. |
|
|
59
|
+
| `timeoutMs` | `number` | `300000` | Per-approval timeout in ms. |
|
|
60
|
+
|
|
61
|
+
## What gets gated
|
|
62
|
+
|
|
63
|
+
The classifier matches OpenClaw skill naming conventions:
|
|
64
|
+
|
|
65
|
+
- **High stakes** (always approval): `*_send`, `*_post`, `*_publish`, `*_call`, `*_dial`, `*_charge`, `*_pay`, `*_delete`, `*_drop`, `*_deploy`, `*_release`, ...
|
|
66
|
+
- **Review** (approval at default `minTier`): `*_create`, `*_update`, `*_write`, `*_edit`, `*_rename`, `*_modify`, and unknown tool names.
|
|
67
|
+
- **Warning** (log / allow): lower-risk state changes from shared SDK classifiers.
|
|
68
|
+
- **Safe** (never gated): `get_*`, `list_*`, `search_*`, `read_*`, `find_*`.
|
|
69
|
+
- **Bash / shell / exec**: defers to the `@oked/sdk` shell classifier (`rm -rf`, `git push --force`, ...).
|
|
70
|
+
|
|
71
|
+
For tool names the classifier doesn't recognize, the default tier is `review`, so they are gated by the default configuration.
|
|
72
|
+
|
|
73
|
+
## Degraded-mode behavior
|
|
74
|
+
|
|
75
|
+
Explicit denials, invalid API keys, missing API keys, and unexpected plugin errors deny sensitive tool calls. If the backend is unreachable, OKed denies `high_stakes` actions and, by default, allows lower tiers so a temporary outage does not stop every OpenClaw workflow. Set `OKED_STRICT_FAIL_CLOSED=1` to deny every sensitive action during backend outages.
|
|
76
|
+
|
|
77
|
+
## Comparison to OpenClaw built-in approvals
|
|
78
|
+
|
|
79
|
+
| | OpenClaw `exec.approval.*` | `@oked/openclaw` |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| Shell commands | OK | OK (via classifier) |
|
|
82
|
+
| Skill / plugin tools | Only if skill author opts in | OK all tools |
|
|
83
|
+
| Mobile push | iOS app paired via `device-pairing` | OKed mobile app, unified across Claude Code + OpenClaw + future tools |
|
|
84
|
+
| Audit log | Gateway-local | Centralized OKed dashboard |
|
|
85
|
+
|
|
86
|
+
Use both. OpenClaw's exec approvals stay in place; this plugin layers on top to cover the skill surface.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm install
|
|
92
|
+
npm run build
|
|
93
|
+
npm test
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type RiskTier } from "@oked/sdk";
|
|
2
|
+
export interface ClassifyOptions {
|
|
3
|
+
alwaysApprove?: ReadonlyArray<string>;
|
|
4
|
+
alwaysAllow?: ReadonlyArray<string>;
|
|
5
|
+
}
|
|
6
|
+
export declare function classifyOpenClawTool(toolName: string, toolInput: Record<string, unknown>, opts?: ClassifyOptions): RiskTier;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { classify as classifyClaude } from "@oked/sdk";
|
|
2
|
+
/**
|
|
3
|
+
* OpenClaw-aware risk classifier.
|
|
4
|
+
*
|
|
5
|
+
* OpenClaw skills register tools with names like `imessage_send`, `phone_call`,
|
|
6
|
+
* `deploy_site`, `email_send`. Names that the @oked/sdk Claude-Code classifier
|
|
7
|
+
* doesn't recognize. We layer a suffix/prefix heuristic for OpenClaw skill
|
|
8
|
+
* conventions on top of the @oked/sdk classifier so well-known sensitive
|
|
9
|
+
* patterns (send, dial, deploy, charge, delete) trip approval automatically.
|
|
10
|
+
*
|
|
11
|
+
* Plugin users can override or extend behavior via plugin config:
|
|
12
|
+
* {
|
|
13
|
+
* "alwaysApprove": ["custom_tool_name", ...],
|
|
14
|
+
* "alwaysAllow": ["safe_tool_name", ...]
|
|
15
|
+
* }
|
|
16
|
+
*/
|
|
17
|
+
const SAFE_PATTERNS = [
|
|
18
|
+
/^get_/,
|
|
19
|
+
/^list_/,
|
|
20
|
+
/^search_/,
|
|
21
|
+
/^read_/,
|
|
22
|
+
/^find_/,
|
|
23
|
+
/^show_/,
|
|
24
|
+
/^check_/,
|
|
25
|
+
/^view_/,
|
|
26
|
+
/^describe_/,
|
|
27
|
+
/^inspect_/,
|
|
28
|
+
/_status$/,
|
|
29
|
+
/_info$/,
|
|
30
|
+
/_count$/,
|
|
31
|
+
/_exists$/,
|
|
32
|
+
/_version$/,
|
|
33
|
+
/_health$/,
|
|
34
|
+
];
|
|
35
|
+
const HIGH_STAKES_PATTERNS = [
|
|
36
|
+
// Communication that touches the real world
|
|
37
|
+
/(_|^)(send|reply|post|publish|broadcast|email|sms|imessage|whatsapp|slack|telegram|tweet|toot)$/,
|
|
38
|
+
/(_|^)(send|reply|post|publish|email|sms|imessage|whatsapp|slack|telegram|tweet)_/,
|
|
39
|
+
// Telephony
|
|
40
|
+
/(_|^)(call|dial|hangup|phone)$/,
|
|
41
|
+
/(_|^)(call|dial|hangup|phone)_/,
|
|
42
|
+
// Money / commerce
|
|
43
|
+
/(_|^)(charge|pay|transfer|refund|invoice|purchase|buy|sell)$/,
|
|
44
|
+
/(_|^)(charge|pay|transfer|refund|invoice|purchase|buy|sell)_/,
|
|
45
|
+
// Destructive
|
|
46
|
+
/(_|^)(delete|drop|destroy|wipe|truncate|remove)$/,
|
|
47
|
+
/(_|^)(delete|drop|destroy|wipe|truncate|remove)_/,
|
|
48
|
+
// Deploys / external publishing
|
|
49
|
+
/(_|^)(deploy|publish|release|launch|ship)$/,
|
|
50
|
+
/(_|^)(deploy|publish|release|launch|ship)_/,
|
|
51
|
+
// Account / identity changes
|
|
52
|
+
/(_|^)(rename|reset_password|revoke|grant|invite_user|remove_user)$/,
|
|
53
|
+
// Calendar / scheduling
|
|
54
|
+
/(_|^)(schedule_meeting|cancel_meeting|book|reschedule)$/,
|
|
55
|
+
];
|
|
56
|
+
const REVIEW_PATTERNS = [
|
|
57
|
+
/(_|^)(create|update|edit|write|modify|patch|set)$/,
|
|
58
|
+
/(_|^)(create|update|edit|write|modify|patch|set)_/,
|
|
59
|
+
/(_|^)(rename|move|copy)_/,
|
|
60
|
+
];
|
|
61
|
+
export function classifyOpenClawTool(toolName, toolInput, opts) {
|
|
62
|
+
const lower = toolName.toLowerCase();
|
|
63
|
+
if (opts?.alwaysAllow?.includes(toolName))
|
|
64
|
+
return "safe";
|
|
65
|
+
if (opts?.alwaysApprove?.includes(toolName))
|
|
66
|
+
return "high_stakes";
|
|
67
|
+
// Bash / shell / exec: defer to the SDK's bash classifier.
|
|
68
|
+
if (lower === "bash" || lower === "shell" || lower === "exec") {
|
|
69
|
+
return classifyClaude("Bash", toolInput);
|
|
70
|
+
}
|
|
71
|
+
for (const re of HIGH_STAKES_PATTERNS) {
|
|
72
|
+
if (re.test(lower))
|
|
73
|
+
return "high_stakes";
|
|
74
|
+
}
|
|
75
|
+
for (const re of SAFE_PATTERNS) {
|
|
76
|
+
if (re.test(lower))
|
|
77
|
+
return "safe";
|
|
78
|
+
}
|
|
79
|
+
for (const re of REVIEW_PATTERNS) {
|
|
80
|
+
if (re.test(lower))
|
|
81
|
+
return "review";
|
|
82
|
+
}
|
|
83
|
+
return "review";
|
|
84
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @oked/openclaw. OKed plugin for OpenClaw.
|
|
3
|
+
*
|
|
4
|
+
* Registers a `before_tool_call` hook that runs for *every* tool the agent
|
|
5
|
+
* invokes (built-in or skill-registered). For tool calls classified as
|
|
6
|
+
* sensitive, the hook freezes the agent and asks for a human approval via
|
|
7
|
+
* the OKed backend (push to your phone). The agent only proceeds on Approve;
|
|
8
|
+
* any other outcome blocks the call.
|
|
9
|
+
*
|
|
10
|
+
* Failure semantics: fail safe always. If the OKed backend is unreachable,
|
|
11
|
+
* if the approval times out, or if the response is malformed, the tool call
|
|
12
|
+
* is denied. Never let an agent proceed when in doubt.
|
|
13
|
+
*
|
|
14
|
+
* Plugin config (set in openclaw.json under plugins.entries.oked):
|
|
15
|
+
* {
|
|
16
|
+
* "apiKey": "ok_...", // OKed API key (or OKED_API_KEY env)
|
|
17
|
+
* "backendUrl": "https://...", // optional override
|
|
18
|
+
* "alwaysApprove": ["custom_tool"], // additional names to require approval for
|
|
19
|
+
* "alwaysAllow": ["safe_tool"], // names to never gate
|
|
20
|
+
* "minTier": "review" // minimum tier to require approval (default: "review")
|
|
21
|
+
* }
|
|
22
|
+
*/
|
|
23
|
+
import { type RiskTier } from "@oked/sdk";
|
|
24
|
+
import { type ClassifyOptions } from "./classify-openclaw.js";
|
|
25
|
+
interface OkedPluginConfig extends ClassifyOptions {
|
|
26
|
+
apiKey?: string;
|
|
27
|
+
backendUrl?: string;
|
|
28
|
+
minTier?: RiskTier;
|
|
29
|
+
/** Approval timeout in ms; defaults to OKed backend default. */
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
/**
|
|
32
|
+
* When true, an unreachable backend hard-denies every sensitive tool call.
|
|
33
|
+
* When false (default), it degrades to allow for non-high-stakes tiers so a
|
|
34
|
+
* single outage does not mass-abort the agent. high_stakes always denies.
|
|
35
|
+
*/
|
|
36
|
+
strictFailClosed?: boolean;
|
|
37
|
+
}
|
|
38
|
+
interface BeforeToolCallEvent {
|
|
39
|
+
toolName: string;
|
|
40
|
+
params: Record<string, unknown>;
|
|
41
|
+
runId?: string;
|
|
42
|
+
toolCallId?: string;
|
|
43
|
+
}
|
|
44
|
+
interface BeforeToolCallContext {
|
|
45
|
+
toolName: string;
|
|
46
|
+
agentId?: string;
|
|
47
|
+
sessionKey?: string;
|
|
48
|
+
sessionId?: string;
|
|
49
|
+
runId?: string;
|
|
50
|
+
toolCallId?: string;
|
|
51
|
+
}
|
|
52
|
+
interface BeforeToolCallResult {
|
|
53
|
+
block?: boolean;
|
|
54
|
+
blockReason?: string;
|
|
55
|
+
params?: Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
interface PluginLogger {
|
|
58
|
+
debug?: (msg: string) => void;
|
|
59
|
+
info?: (msg: string) => void;
|
|
60
|
+
warn?: (msg: string) => void;
|
|
61
|
+
error?: (msg: string) => void;
|
|
62
|
+
}
|
|
63
|
+
interface OpenClawPluginApi {
|
|
64
|
+
pluginConfig?: OkedPluginConfig;
|
|
65
|
+
logger?: PluginLogger;
|
|
66
|
+
on: (hookName: string, handler: (event: BeforeToolCallEvent, ctx: BeforeToolCallContext) => Promise<BeforeToolCallResult | void> | BeforeToolCallResult | void) => void;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Thrown to abort a tool call OKed denied.
|
|
70
|
+
*
|
|
71
|
+
* Why throw instead of only returning `{ block: true }`: a returned block
|
|
72
|
+
* directive is silently ignored unless the host recognizes that exact shape,
|
|
73
|
+
* which fails OPEN (observed in production: a denied `rm` still executed). A
|
|
74
|
+
* thrown exception from a pre-execution hook is the most universally honored
|
|
75
|
+
* "abort" signal across plugin/hook runtimes, so it is the safe default for a
|
|
76
|
+
* trust tool. `okedBlock`/`blockReason` are kept on the error so hosts that
|
|
77
|
+
* DO inspect a structured result still get one.
|
|
78
|
+
*
|
|
79
|
+
* Caveat: if the host fires `before_tool_call` without awaiting the async
|
|
80
|
+
* handler, neither a throw nor a return can stop the call - that is a host
|
|
81
|
+
* bug OKed cannot fix from the plugin side.
|
|
82
|
+
*/
|
|
83
|
+
export declare class OkedDeniedError extends Error {
|
|
84
|
+
readonly okedBlock: true;
|
|
85
|
+
readonly blockReason: string;
|
|
86
|
+
constructor(reason: string);
|
|
87
|
+
}
|
|
88
|
+
declare const plugin: {
|
|
89
|
+
id: string;
|
|
90
|
+
name: string;
|
|
91
|
+
description: string;
|
|
92
|
+
register(api: OpenClawPluginApi): void;
|
|
93
|
+
};
|
|
94
|
+
export default plugin;
|
|
95
|
+
export { classifyOpenClawTool } from "./classify-openclaw.js";
|
|
96
|
+
export type { OkedPluginConfig };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @oked/openclaw. OKed plugin for OpenClaw.
|
|
3
|
+
*
|
|
4
|
+
* Registers a `before_tool_call` hook that runs for *every* tool the agent
|
|
5
|
+
* invokes (built-in or skill-registered). For tool calls classified as
|
|
6
|
+
* sensitive, the hook freezes the agent and asks for a human approval via
|
|
7
|
+
* the OKed backend (push to your phone). The agent only proceeds on Approve;
|
|
8
|
+
* any other outcome blocks the call.
|
|
9
|
+
*
|
|
10
|
+
* Failure semantics: fail safe always. If the OKed backend is unreachable,
|
|
11
|
+
* if the approval times out, or if the response is malformed, the tool call
|
|
12
|
+
* is denied. Never let an agent proceed when in doubt.
|
|
13
|
+
*
|
|
14
|
+
* Plugin config (set in openclaw.json under plugins.entries.oked):
|
|
15
|
+
* {
|
|
16
|
+
* "apiKey": "ok_...", // OKed API key (or OKED_API_KEY env)
|
|
17
|
+
* "backendUrl": "https://...", // optional override
|
|
18
|
+
* "alwaysApprove": ["custom_tool"], // additional names to require approval for
|
|
19
|
+
* "alwaysAllow": ["safe_tool"], // names to never gate
|
|
20
|
+
* "minTier": "review" // minimum tier to require approval (default: "review")
|
|
21
|
+
* }
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as os from "node:os";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import { OKedClient, describe as describeAction, degradedDecision, OKedAuthError, OKedBackendUnreachableError, TIER_ORDER, } from "@oked/sdk";
|
|
27
|
+
import { classifyOpenClawTool } from "./classify-openclaw.js";
|
|
28
|
+
const MAX_SCRIPT_READ = 4096;
|
|
29
|
+
function tryReadScriptFile(command) {
|
|
30
|
+
// Match: python3 script.py, node script.js, ruby script.rb, etc.
|
|
31
|
+
// Skip -c/-e flags (handled by findSqlInCommand inline path).
|
|
32
|
+
const m = command.trim().match(/\b(?:python\d?(?:\.\d+)?|node|ruby|perl|deno\s+run|bun\s+run)\s+(?:-[^\s]+\s+)*(['"]?)([^\s'"]+\.(?:py|js|mjs|ts|rb|pl))\1/);
|
|
33
|
+
if (!m)
|
|
34
|
+
return undefined;
|
|
35
|
+
let target = m[2];
|
|
36
|
+
if (target.startsWith("~/"))
|
|
37
|
+
target = path.join(os.homedir(), target.slice(2));
|
|
38
|
+
else if (!path.isAbsolute(target))
|
|
39
|
+
target = path.resolve(target);
|
|
40
|
+
try {
|
|
41
|
+
const fd = fs.openSync(target, "r");
|
|
42
|
+
const buf = Buffer.alloc(MAX_SCRIPT_READ);
|
|
43
|
+
const bytesRead = fs.readSync(fd, buf, 0, MAX_SCRIPT_READ, 0);
|
|
44
|
+
fs.closeSync(fd);
|
|
45
|
+
return buf.toString("utf8", 0, bytesRead);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function tryStatRmTarget(command) {
|
|
52
|
+
const m = command.trim().match(/\b(?:rm|trash|trash-put|rmdir)\s+(?:-[^\s]+\s+)*(['"]?)([^\s'"]+)\1/);
|
|
53
|
+
if (!m)
|
|
54
|
+
return undefined;
|
|
55
|
+
let target = m[2];
|
|
56
|
+
if (target.startsWith("~/"))
|
|
57
|
+
target = path.join(os.homedir(), target.slice(2));
|
|
58
|
+
try {
|
|
59
|
+
return fs.statSync(target).size;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const APPROVAL_TIERS = new Set(["review", "high_stakes"]);
|
|
66
|
+
/**
|
|
67
|
+
* Thrown to abort a tool call OKed denied.
|
|
68
|
+
*
|
|
69
|
+
* Why throw instead of only returning `{ block: true }`: a returned block
|
|
70
|
+
* directive is silently ignored unless the host recognizes that exact shape,
|
|
71
|
+
* which fails OPEN (observed in production: a denied `rm` still executed). A
|
|
72
|
+
* thrown exception from a pre-execution hook is the most universally honored
|
|
73
|
+
* "abort" signal across plugin/hook runtimes, so it is the safe default for a
|
|
74
|
+
* trust tool. `okedBlock`/`blockReason` are kept on the error so hosts that
|
|
75
|
+
* DO inspect a structured result still get one.
|
|
76
|
+
*
|
|
77
|
+
* Caveat: if the host fires `before_tool_call` without awaiting the async
|
|
78
|
+
* handler, neither a throw nor a return can stop the call - that is a host
|
|
79
|
+
* bug OKed cannot fix from the plugin side.
|
|
80
|
+
*/
|
|
81
|
+
export class OkedDeniedError extends Error {
|
|
82
|
+
okedBlock = true;
|
|
83
|
+
blockReason;
|
|
84
|
+
constructor(reason) {
|
|
85
|
+
super(reason);
|
|
86
|
+
this.name = "OkedDeniedError";
|
|
87
|
+
this.blockReason = reason;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const PLUGIN_ID = "oked";
|
|
91
|
+
const plugin = {
|
|
92
|
+
id: PLUGIN_ID,
|
|
93
|
+
name: "OKed Approvals",
|
|
94
|
+
description: "Freeze the agent before sensitive tool calls and ask for human approval via the OKed mobile app.",
|
|
95
|
+
register(api) {
|
|
96
|
+
const cfg = api.pluginConfig ?? {};
|
|
97
|
+
const log = api.logger;
|
|
98
|
+
const oked = new OKedClient({
|
|
99
|
+
apiKey: cfg.apiKey ?? process.env.OKED_API_KEY,
|
|
100
|
+
backendUrl: cfg.backendUrl ?? process.env.OKED_BACKEND_URL,
|
|
101
|
+
...(cfg.timeoutMs ? { timeout: cfg.timeoutMs } : {}),
|
|
102
|
+
...(cfg.strictFailClosed !== undefined
|
|
103
|
+
? { strictFailClosed: cfg.strictFailClosed }
|
|
104
|
+
: {}),
|
|
105
|
+
});
|
|
106
|
+
if (!oked.apiKey) {
|
|
107
|
+
log?.warn?.(`[oked] no apiKey configured. Plugin will fail-safe DENY every sensitive tool call. ` +
|
|
108
|
+
`Set OKED_API_KEY env var or plugins.entries.oked.apiKey in openclaw.json.`);
|
|
109
|
+
}
|
|
110
|
+
// minTier resolution order: openclaw.json > OKED_MIN_TIER env var > default.
|
|
111
|
+
// (The env-var fallback exists because some OpenClaw versions reject
|
|
112
|
+
// unknown keys under plugins.entries.<id>, so cfg.minTier is unavailable.)
|
|
113
|
+
const envMinTier = process.env.OKED_MIN_TIER;
|
|
114
|
+
const resolvedMinTier = cfg.minTier ?? (envMinTier && envMinTier in TIER_ORDER ? envMinTier : undefined) ?? "review";
|
|
115
|
+
const minTier = resolvedMinTier;
|
|
116
|
+
const minTierLevel = TIER_ORDER[minTier];
|
|
117
|
+
api.on("before_tool_call", async (event, ctx) => {
|
|
118
|
+
const { toolName, params } = event;
|
|
119
|
+
// Presence ping (throttled to once/day on disk, never throws). Fired for
|
|
120
|
+
// every tool call, before the allow-fast-path below, so installs that
|
|
121
|
+
// only run low-tier tools still register for retention. Fire-and-forget:
|
|
122
|
+
// this is a long-lived process, so we never add latency to the call.
|
|
123
|
+
if (oked.apiKey)
|
|
124
|
+
void oked.heartbeat().catch(() => { });
|
|
125
|
+
// Why return `{ block: true, blockReason }` instead of throwing:
|
|
126
|
+
// OpenClaw's `runBeforeToolCallHook` wraps any thrown error - even our
|
|
127
|
+
// OkedDeniedError - into a generic `kind: "failure"` outcome and
|
|
128
|
+
// overwrites the reason with the hardcoded string
|
|
129
|
+
// "Tool call blocked because before_tool_call hook failed". That generic
|
|
130
|
+
// string is what reaches the LLM, which reads it as a transient
|
|
131
|
+
// tool-runtime error and retries the SAME denied action (observed in
|
|
132
|
+
// production: 17 retries of a denied DELETE).
|
|
133
|
+
//
|
|
134
|
+
// Returning `{ block: true, blockReason }` instead routes through the
|
|
135
|
+
// host's `kind: "veto"` path: the tool is NOT executed, AND our reason
|
|
136
|
+
// is surfaced verbatim to the agent via `buildBlockedToolResult`. So
|
|
137
|
+
// the LLM actually reads our "do NOT retry, ask the user" instruction.
|
|
138
|
+
//
|
|
139
|
+
// History: an earlier OpenClaw build did not honor a returned veto
|
|
140
|
+
// (the tool ran anyway), which is why this code originally threw. The
|
|
141
|
+
// current host (v2026.5.4+) correctly blocks on the return path - it
|
|
142
|
+
// builds a blocked tool result without ever calling `tool.execute`.
|
|
143
|
+
const deny = (reason) => {
|
|
144
|
+
log?.info?.(`[oked] X blocking ${toolName}: ${reason}`);
|
|
145
|
+
return { block: true, blockReason: reason };
|
|
146
|
+
};
|
|
147
|
+
let tier;
|
|
148
|
+
try {
|
|
149
|
+
tier = classifyOpenClawTool(toolName, params, {
|
|
150
|
+
alwaysApprove: cfg.alwaysApprove,
|
|
151
|
+
alwaysAllow: cfg.alwaysAllow,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
// Classifier should never throw, but if it does, fail safe.
|
|
156
|
+
log?.error?.(`[oked] classifier error on ${toolName}: ${String(err)}`);
|
|
157
|
+
return deny("OKed classifier error. Fail-safe deny.");
|
|
158
|
+
}
|
|
159
|
+
// Warning is informational only; only review/high_stakes can block.
|
|
160
|
+
if (TIER_ORDER[tier] < minTierLevel || !APPROVAL_TIERS.has(tier)) {
|
|
161
|
+
return; // void = allow
|
|
162
|
+
}
|
|
163
|
+
// Sensitive. Ask the human.
|
|
164
|
+
const description = describeAction(toolName, params);
|
|
165
|
+
log?.info?.(`[oked] requesting approval: tool=${toolName} tier=${tier} session=${ctx.sessionKey ?? ctx.sessionId ?? "?"}`);
|
|
166
|
+
// Enrich tool_input with file size for delete operations so the backend
|
|
167
|
+
// can display it in the approval card (same as Create file shows size).
|
|
168
|
+
let enrichedParams = params;
|
|
169
|
+
const lowerTool = toolName.toLowerCase();
|
|
170
|
+
if (lowerTool === "bash" || lowerTool === "shell" || lowerTool === "exec") {
|
|
171
|
+
const cmd = (params.command ?? params.cmd);
|
|
172
|
+
if (typeof cmd === "string") {
|
|
173
|
+
const sizeBytes = tryStatRmTarget(cmd);
|
|
174
|
+
if (sizeBytes !== undefined)
|
|
175
|
+
enrichedParams = { ...params, _file_size_bytes: sizeBytes };
|
|
176
|
+
const scriptBody = tryReadScriptFile(cmd);
|
|
177
|
+
if (scriptBody !== undefined)
|
|
178
|
+
enrichedParams = { ...enrichedParams, _script_body: scriptBody };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Compute the outcome first; only throw/return AFTER the try/catch so a
|
|
182
|
+
// deny is never swallowed by this block's own catch.
|
|
183
|
+
let outcome;
|
|
184
|
+
try {
|
|
185
|
+
const result = await oked.approve({
|
|
186
|
+
action: toolName,
|
|
187
|
+
description,
|
|
188
|
+
tier,
|
|
189
|
+
tool_input: enrichedParams,
|
|
190
|
+
...(ctx.sessionId ? { session_id: ctx.sessionId } : {}),
|
|
191
|
+
...(ctx.sessionKey && !ctx.sessionId ? { session_id: ctx.sessionKey } : {}),
|
|
192
|
+
cwd: process.cwd(),
|
|
193
|
+
});
|
|
194
|
+
if (result.approved) {
|
|
195
|
+
outcome = "allow";
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
// Phrase the denial as an explicit instruction to the agent. A terse
|
|
199
|
+
// "denied via OKed" message gets interpreted as a transient tool
|
|
200
|
+
// error and the agent retries (observed: 17 retries of the same
|
|
201
|
+
// DELETE before the agent gave up). Spell out that this is a final
|
|
202
|
+
// human decision so the LLM stops looping.
|
|
203
|
+
const verb = result.decision === "timeout" ? "did not respond to" : "DENIED";
|
|
204
|
+
outcome =
|
|
205
|
+
`USER ${verb} this action via OKed (approval ${result.approval_id}). ` +
|
|
206
|
+
`This is a final human decision - do NOT retry the same action. ` +
|
|
207
|
+
`Stop and ask the user what to do instead.`;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
if (err instanceof OKedAuthError) {
|
|
212
|
+
// Auth misconfig is not an outage - always deny.
|
|
213
|
+
log?.error?.(`[oked] invalid API key for ${toolName}.`);
|
|
214
|
+
outcome =
|
|
215
|
+
"OKed: invalid API key (configuration error, not a transient failure). " +
|
|
216
|
+
"Do NOT retry. Stop and report this to the user.";
|
|
217
|
+
}
|
|
218
|
+
else if (err instanceof OKedBackendUnreachableError) {
|
|
219
|
+
const decision = degradedDecision(tier, {
|
|
220
|
+
strictFailClosed: oked.strictFailClosed,
|
|
221
|
+
});
|
|
222
|
+
if (decision === "allow") {
|
|
223
|
+
// OpenClaw has no native "ask"; degraded non-high-stakes proceeds.
|
|
224
|
+
log?.warn?.(`[oked] backend unreachable - ${toolName} (${tier}) allowed (degraded; non-high-stakes)`);
|
|
225
|
+
outcome = "allow";
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const why = oked.strictFailClosed ? "strict fail-closed" : "high-stakes";
|
|
229
|
+
log?.error?.(`[oked] backend unreachable - ${toolName} (${tier}) denied (${why}, fail-safe)`);
|
|
230
|
+
outcome =
|
|
231
|
+
`OKed backend unreachable - this ${tier} action is denied (${why}, fail-safe). ` +
|
|
232
|
+
`Do NOT retry. Stop and ask the user how to proceed.`;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
237
|
+
log?.error?.(`[oked] approval request failed for ${toolName}: ${msg}.`);
|
|
238
|
+
outcome =
|
|
239
|
+
`OKed backend error, fail-safe deny: ${msg}. ` +
|
|
240
|
+
`Do NOT retry. Stop and ask the user how to proceed.`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (outcome === "allow") {
|
|
244
|
+
log?.info?.(`[oked] OK ${toolName} allowed`);
|
|
245
|
+
return; // allow
|
|
246
|
+
}
|
|
247
|
+
return deny(outcome); // throws OkedDeniedError - strongest abort signal
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
export default plugin;
|
|
252
|
+
export { classifyOpenClawTool } from "./classify-openclaw.js";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "oked",
|
|
3
|
+
"name": "OKed",
|
|
4
|
+
"description": "Human approval layer for AI agents - freezes the agent before sensitive tool calls and asks for human approval via Telegram or the OKed dashboard.",
|
|
5
|
+
"activation": {
|
|
6
|
+
"onStartup": true
|
|
7
|
+
},
|
|
8
|
+
"configSchema": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"additionalProperties": false,
|
|
11
|
+
"properties": {
|
|
12
|
+
"enabled": {
|
|
13
|
+
"type": "boolean"
|
|
14
|
+
},
|
|
15
|
+
"apiKey": {
|
|
16
|
+
"type": "string"
|
|
17
|
+
},
|
|
18
|
+
"backendUrl": {
|
|
19
|
+
"type": "string"
|
|
20
|
+
},
|
|
21
|
+
"minTier": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"enum": ["safe", "warning", "review", "high_stakes"],
|
|
24
|
+
"default": "review"
|
|
25
|
+
},
|
|
26
|
+
"alwaysApprove": {
|
|
27
|
+
"type": "array",
|
|
28
|
+
"items": { "type": "string" }
|
|
29
|
+
},
|
|
30
|
+
"alwaysAllow": {
|
|
31
|
+
"type": "array",
|
|
32
|
+
"items": { "type": "string" }
|
|
33
|
+
},
|
|
34
|
+
"timeoutMs": {
|
|
35
|
+
"type": "integer",
|
|
36
|
+
"minimum": 1000,
|
|
37
|
+
"maximum": 600000
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"uiHints": {
|
|
42
|
+
"enabled": {
|
|
43
|
+
"label": "OKed Approvals",
|
|
44
|
+
"help": "Globally enable or pause the OKed approval gate."
|
|
45
|
+
},
|
|
46
|
+
"apiKey": {
|
|
47
|
+
"label": "OKed API Key",
|
|
48
|
+
"placeholder": "ok_...",
|
|
49
|
+
"help": "Your OKed API key from https://app.oked.ai/dashboard."
|
|
50
|
+
},
|
|
51
|
+
"backendUrl": {
|
|
52
|
+
"label": "Backend URL",
|
|
53
|
+
"placeholder": "https://api.oked.ai",
|
|
54
|
+
"help": "Override the OKed backend URL. Default is the hosted backend."
|
|
55
|
+
},
|
|
56
|
+
"minTier": {
|
|
57
|
+
"label": "Minimum Approval Tier",
|
|
58
|
+
"help": "Lowest risk tier that requires human approval. Use 'review' to gate everything except clearly safe reads, 'warning' for state-changing actions only, or 'high_stakes' for the most dangerous calls only."
|
|
59
|
+
},
|
|
60
|
+
"alwaysApprove": {
|
|
61
|
+
"label": "Always Require Approval",
|
|
62
|
+
"help": "Tool names that should always require approval, regardless of the classifier."
|
|
63
|
+
},
|
|
64
|
+
"alwaysAllow": {
|
|
65
|
+
"label": "Always Allow",
|
|
66
|
+
"help": "Tool names to never gate. Use sparingly."
|
|
67
|
+
},
|
|
68
|
+
"timeoutMs": {
|
|
69
|
+
"label": "Approval Timeout (ms)",
|
|
70
|
+
"help": "How long to wait for a human decision before the action is denied. Defaults to the SDK's value."
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oked/openclaw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OKed plugin for OpenClaw. Gates skill and tool calls behind a human approval push to your phone.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"openclaw.plugin.json",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"prebuild": "npm run build --workspace=@oked/sdk",
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"dev": "tsc --watch",
|
|
23
|
+
"test": "npm run build && node src/smoke.test.mjs",
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"openclaw": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./dist/index.js"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"openclaw": ">=2026.4.20"
|
|
33
|
+
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"openclaw": {
|
|
36
|
+
"optional": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@oked/sdk": "^0.1.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^22.13.10",
|
|
44
|
+
"typescript": "^5.6.0"
|
|
45
|
+
},
|
|
46
|
+
"keywords": [
|
|
47
|
+
"openclaw",
|
|
48
|
+
"openclaw-plugin",
|
|
49
|
+
"approval",
|
|
50
|
+
"agents",
|
|
51
|
+
"ai",
|
|
52
|
+
"human-in-the-loop",
|
|
53
|
+
"oked"
|
|
54
|
+
],
|
|
55
|
+
"repository": {
|
|
56
|
+
"type": "git",
|
|
57
|
+
"url": "git+https://github.com/oked-ai/oked-sdk.git"
|
|
58
|
+
},
|
|
59
|
+
"bugs": {
|
|
60
|
+
"url": "https://github.com/oked-ai/oked-sdk/issues"
|
|
61
|
+
},
|
|
62
|
+
"homepage": "https://github.com/oked-ai/oked-sdk/tree/main/packages/openclaw#readme",
|
|
63
|
+
"license": "MIT",
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=18"
|
|
66
|
+
},
|
|
67
|
+
"publishConfig": {
|
|
68
|
+
"access": "public"
|
|
69
|
+
}
|
|
70
|
+
}
|