openclaw-plugin-exec-grant 1.0.2 → 1.2.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/index.ts +283 -5
- package/openclaw.plugin.json +14 -6
- package/package.json +1 -1
- package/skills/exec-grant/SKILL.md +2 -2
- package/skills/exec-grant/references/security-model.md +6 -6
- package/skills/exec-grant/scripts/grant.sh +6 -47
- package/skills/exec-grant/scripts/request.sh +18 -12
- package/skills/exec-grant/scripts/revoke.sh +13 -5
package/index.ts
CHANGED
|
@@ -1,14 +1,55 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
3
5
|
|
|
6
|
+
const PLUGIN_ID = "openclaw-plugin-exec-grant";
|
|
4
7
|
const SCRIPTS_DIR = join(__dirname, "skills", "exec-grant", "scripts");
|
|
8
|
+
const APPROVALS_FILE = join(homedir(), ".openclaw", "exec-approvals.json");
|
|
5
9
|
|
|
6
|
-
|
|
10
|
+
const DEFAULT_ALLOWLIST = [
|
|
11
|
+
"ls",
|
|
12
|
+
"cat",
|
|
13
|
+
"head",
|
|
14
|
+
"tail",
|
|
15
|
+
"wc",
|
|
16
|
+
"grep",
|
|
17
|
+
"find",
|
|
18
|
+
"file",
|
|
19
|
+
"echo",
|
|
20
|
+
"printf",
|
|
21
|
+
"date",
|
|
22
|
+
"whoami",
|
|
23
|
+
"pwd",
|
|
24
|
+
"git status",
|
|
25
|
+
"git log",
|
|
26
|
+
"git diff",
|
|
27
|
+
"git show",
|
|
28
|
+
"node --version",
|
|
29
|
+
"npm --version",
|
|
30
|
+
"python3 --version",
|
|
31
|
+
"openclaw config get",
|
|
32
|
+
"openclaw message send",
|
|
33
|
+
"jq",
|
|
34
|
+
"sed",
|
|
35
|
+
"awk",
|
|
36
|
+
"sort",
|
|
37
|
+
"uniq",
|
|
38
|
+
"tr",
|
|
39
|
+
"cut",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function runScript(
|
|
43
|
+
name: string,
|
|
44
|
+
args: string[] = [],
|
|
45
|
+
env?: Record<string, string>,
|
|
46
|
+
): string {
|
|
7
47
|
const scriptPath = join(SCRIPTS_DIR, name);
|
|
8
48
|
try {
|
|
9
49
|
return execFileSync("bash", [scriptPath, ...args], {
|
|
10
50
|
encoding: "utf-8",
|
|
11
51
|
timeout: 30_000,
|
|
52
|
+
env: { ...process.env, ...env },
|
|
12
53
|
}).trim();
|
|
13
54
|
} catch (err: any) {
|
|
14
55
|
const stderr = err.stderr?.toString().trim() || "";
|
|
@@ -17,7 +58,190 @@ function runScript(name: string, args: string[] = []): string {
|
|
|
17
58
|
}
|
|
18
59
|
}
|
|
19
60
|
|
|
61
|
+
function ensureAllowlistBaseline(logger: any): void {
|
|
62
|
+
mkdirSync(dirname(APPROVALS_FILE), { recursive: true });
|
|
63
|
+
|
|
64
|
+
if (!existsSync(APPROVALS_FILE)) {
|
|
65
|
+
const baseline = {
|
|
66
|
+
agents: {
|
|
67
|
+
main: {
|
|
68
|
+
security: "allowlist",
|
|
69
|
+
allowlist: DEFAULT_ALLOWLIST,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
writeFileSync(APPROVALS_FILE, JSON.stringify(baseline, null, 2) + "\n");
|
|
74
|
+
logger?.info(
|
|
75
|
+
`[exec-grant] Created ${APPROVALS_FILE} with allowlist baseline (${DEFAULT_ALLOWLIST.length} commands)`,
|
|
76
|
+
);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// File exists -- check if security mode is set
|
|
81
|
+
try {
|
|
82
|
+
const data = JSON.parse(readFileSync(APPROVALS_FILE, "utf-8"));
|
|
83
|
+
const security = data?.agents?.main?.security;
|
|
84
|
+
if (!security) {
|
|
85
|
+
// Set security to allowlist without overwriting other fields
|
|
86
|
+
if (!data.agents) data.agents = {};
|
|
87
|
+
if (!data.agents.main) data.agents.main = {};
|
|
88
|
+
data.agents.main.security = "allowlist";
|
|
89
|
+
if (!data.agents.main.allowlist) {
|
|
90
|
+
data.agents.main.allowlist = DEFAULT_ALLOWLIST;
|
|
91
|
+
}
|
|
92
|
+
writeFileSync(APPROVALS_FILE, JSON.stringify(data, null, 2) + "\n");
|
|
93
|
+
logger?.info(
|
|
94
|
+
`[exec-grant] Set agents.main.security = "allowlist" in ${APPROVALS_FILE}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// File exists but isn't valid JSON -- leave it alone
|
|
99
|
+
logger?.warn(`[exec-grant] Could not parse ${APPROVALS_FILE}, skipping baseline setup`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
20
103
|
export default function register(api: any) {
|
|
104
|
+
const logger = api.logger;
|
|
105
|
+
const pluginConfig = api.pluginConfig ?? {};
|
|
106
|
+
|
|
107
|
+
// --- Auto-create allowlist baseline ---
|
|
108
|
+
ensureAllowlistBaseline(logger);
|
|
109
|
+
|
|
110
|
+
// --- Startup guidance ---
|
|
111
|
+
const adminTarget = pluginConfig.adminTarget;
|
|
112
|
+
const channel = pluginConfig.channel;
|
|
113
|
+
|
|
114
|
+
if (!adminTarget || !channel) {
|
|
115
|
+
logger?.warn(
|
|
116
|
+
`[exec-grant] Plugin installed but not configured yet.\n` +
|
|
117
|
+
` Run: openclaw exec-grant-setup\n` +
|
|
118
|
+
` Or manually set in openclaw.json:\n` +
|
|
119
|
+
` plugins.entries.${PLUGIN_ID}.config.adminTarget = "<target>"\n` +
|
|
120
|
+
` plugins.entries.${PLUGIN_ID}.config.channel = "<channel>"`,
|
|
121
|
+
);
|
|
122
|
+
} else {
|
|
123
|
+
logger?.info(
|
|
124
|
+
`[exec-grant] Ready. Admin: ${adminTarget} via ${channel}. ` +
|
|
125
|
+
`Commands: /grant <min>, /revoke, /grant-status`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- Interactive setup CLI command ---
|
|
130
|
+
api.registerCli(
|
|
131
|
+
({ program, config }: any) => {
|
|
132
|
+
program
|
|
133
|
+
.command("exec-grant-setup")
|
|
134
|
+
.description("Configure the exec-grant plugin interactively")
|
|
135
|
+
.action(async () => {
|
|
136
|
+
// Dynamic import for prompts (clack is bundled with openclaw)
|
|
137
|
+
let prompts: any;
|
|
138
|
+
try {
|
|
139
|
+
prompts = await import("@clack/prompts");
|
|
140
|
+
} catch {
|
|
141
|
+
// Fallback: try readline
|
|
142
|
+
console.log("\n exec-grant setup\n");
|
|
143
|
+
console.log(" Could not load interactive prompts.");
|
|
144
|
+
console.log(" Configure manually in openclaw.json:\n");
|
|
145
|
+
printManualConfig();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
prompts.intro("exec-grant setup");
|
|
150
|
+
|
|
151
|
+
const currentConfig =
|
|
152
|
+
config?.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
|
|
153
|
+
|
|
154
|
+
const channelChoice = await prompts.select({
|
|
155
|
+
message: "Which messaging channel should approval requests use?",
|
|
156
|
+
options: [
|
|
157
|
+
{ value: "whatsapp", label: "WhatsApp" },
|
|
158
|
+
{ value: "telegram", label: "Telegram" },
|
|
159
|
+
{ value: "slack", label: "Slack" },
|
|
160
|
+
{ value: "discord", label: "Discord" },
|
|
161
|
+
{ value: "signal", label: "Signal" },
|
|
162
|
+
{ value: "msteams", label: "Microsoft Teams" },
|
|
163
|
+
{ value: "matrix", label: "Matrix" },
|
|
164
|
+
{ value: "other", label: "Other" },
|
|
165
|
+
],
|
|
166
|
+
initialValue: currentConfig.channel || "whatsapp",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (prompts.isCancel(channelChoice)) {
|
|
170
|
+
prompts.cancel("Setup cancelled.");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let selectedChannel = channelChoice as string;
|
|
175
|
+
if (selectedChannel === "other") {
|
|
176
|
+
const custom = await prompts.text({
|
|
177
|
+
message: "Enter the channel name:",
|
|
178
|
+
placeholder: "nostr",
|
|
179
|
+
});
|
|
180
|
+
if (prompts.isCancel(custom)) {
|
|
181
|
+
prompts.cancel("Setup cancelled.");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
selectedChannel = custom as string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const targetHint = getTargetHint(selectedChannel);
|
|
188
|
+
const target = await prompts.text({
|
|
189
|
+
message: `Admin contact (${targetHint.label}):`,
|
|
190
|
+
placeholder: targetHint.placeholder,
|
|
191
|
+
initialValue: currentConfig.adminTarget || "",
|
|
192
|
+
validate: (val: string) =>
|
|
193
|
+
val.trim().length === 0 ? "Required" : undefined,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (prompts.isCancel(target)) {
|
|
197
|
+
prompts.cancel("Setup cancelled.");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Write config
|
|
202
|
+
console.log("");
|
|
203
|
+
try {
|
|
204
|
+
execFileSync("bash", [
|
|
205
|
+
"-c",
|
|
206
|
+
`openclaw config set "plugins.entries.${PLUGIN_ID}.config.channel" "${selectedChannel}" && ` +
|
|
207
|
+
`openclaw config set "plugins.entries.${PLUGIN_ID}.config.adminTarget" "${(target as string).trim()}"`,
|
|
208
|
+
], { encoding: "utf-8", timeout: 15_000 });
|
|
209
|
+
} catch {
|
|
210
|
+
prompts.log.error("Failed to write config. Set manually:");
|
|
211
|
+
printManualConfig(selectedChannel, (target as string).trim());
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
prompts.log.success(
|
|
216
|
+
`Channel: ${selectedChannel}\n Admin: ${(target as string).trim()}`,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// Check allowlist baseline
|
|
220
|
+
if (existsSync(APPROVALS_FILE)) {
|
|
221
|
+
try {
|
|
222
|
+
const data = JSON.parse(readFileSync(APPROVALS_FILE, "utf-8"));
|
|
223
|
+
const sec = data?.agents?.main?.security;
|
|
224
|
+
prompts.log.info(
|
|
225
|
+
`Allowlist baseline: ${APPROVALS_FILE}\n Security mode: ${sec || "not set"}`,
|
|
226
|
+
);
|
|
227
|
+
} catch {
|
|
228
|
+
// skip
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
prompts.log.info(
|
|
233
|
+
"Restart the gateway to apply changes:\n openclaw gateway restart",
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
prompts.outro(
|
|
237
|
+
"Setup complete! Commands available: /grant <min>, /revoke, /grant-status",
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
},
|
|
241
|
+
{ commands: ["exec-grant-setup"] },
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// --- Slash commands ---
|
|
21
245
|
api.registerCommand({
|
|
22
246
|
name: "grant",
|
|
23
247
|
description: "Activate time-boxed elevated shell access (admin only)",
|
|
@@ -28,7 +252,12 @@ export default function register(api: any) {
|
|
|
28
252
|
if (!minutes || !/^\d+$/.test(minutes)) {
|
|
29
253
|
return { text: "Usage: /grant <minutes> (1-120)" };
|
|
30
254
|
}
|
|
31
|
-
const
|
|
255
|
+
const replyChannel = ctx.channel || channel;
|
|
256
|
+
const replyTarget = ctx.sender || adminTarget;
|
|
257
|
+
const result = runScript("grant.sh", [minutes], {
|
|
258
|
+
EXEC_GRANT_CHANNEL: replyChannel || "",
|
|
259
|
+
EXEC_GRANT_TARGET: replyTarget || "",
|
|
260
|
+
});
|
|
32
261
|
return { text: result };
|
|
33
262
|
},
|
|
34
263
|
});
|
|
@@ -38,8 +267,13 @@ export default function register(api: any) {
|
|
|
38
267
|
description: "Revoke elevated shell access immediately (admin only)",
|
|
39
268
|
acceptsArgs: false,
|
|
40
269
|
requireAuth: true,
|
|
41
|
-
handler() {
|
|
42
|
-
const
|
|
270
|
+
handler(ctx: any) {
|
|
271
|
+
const replyChannel = ctx.channel || channel;
|
|
272
|
+
const replyTarget = ctx.sender || adminTarget;
|
|
273
|
+
const result = runScript("revoke.sh", [], {
|
|
274
|
+
EXEC_GRANT_CHANNEL: replyChannel || "",
|
|
275
|
+
EXEC_GRANT_TARGET: replyTarget || "",
|
|
276
|
+
});
|
|
43
277
|
return { text: result };
|
|
44
278
|
},
|
|
45
279
|
});
|
|
@@ -55,3 +289,47 @@ export default function register(api: any) {
|
|
|
55
289
|
},
|
|
56
290
|
});
|
|
57
291
|
}
|
|
292
|
+
|
|
293
|
+
function getTargetHint(channel: string): {
|
|
294
|
+
label: string;
|
|
295
|
+
placeholder: string;
|
|
296
|
+
} {
|
|
297
|
+
switch (channel) {
|
|
298
|
+
case "whatsapp":
|
|
299
|
+
case "signal":
|
|
300
|
+
return { label: "E.164 phone number", placeholder: "+16505551234" };
|
|
301
|
+
case "telegram":
|
|
302
|
+
return { label: "chat ID or @username", placeholder: "@admin" };
|
|
303
|
+
case "slack":
|
|
304
|
+
return {
|
|
305
|
+
label: "channel or user ID",
|
|
306
|
+
placeholder: "#approvals or @admin",
|
|
307
|
+
};
|
|
308
|
+
case "discord":
|
|
309
|
+
return { label: "channel or user ID", placeholder: "#approvals" };
|
|
310
|
+
case "msteams":
|
|
311
|
+
return { label: "channel or user", placeholder: "user@company.com" };
|
|
312
|
+
case "matrix":
|
|
313
|
+
return { label: "room or user", placeholder: "@admin:matrix.org" };
|
|
314
|
+
default:
|
|
315
|
+
return { label: "recipient identifier", placeholder: "admin" };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function printManualConfig(channel?: string, target?: string): void {
|
|
320
|
+
console.log(` "plugins": {`);
|
|
321
|
+
console.log(` "entries": {`);
|
|
322
|
+
console.log(` "${PLUGIN_ID}": {`);
|
|
323
|
+
console.log(` "enabled": true,`);
|
|
324
|
+
console.log(` "config": {`);
|
|
325
|
+
console.log(
|
|
326
|
+
` "adminTarget": "${target || "<your-admin-contact>"}"`,
|
|
327
|
+
);
|
|
328
|
+
console.log(
|
|
329
|
+
` "channel": "${channel || "<whatsapp|telegram|slack|...>"}"`,
|
|
330
|
+
);
|
|
331
|
+
console.log(` }`);
|
|
332
|
+
console.log(` }`);
|
|
333
|
+
console.log(` }`);
|
|
334
|
+
console.log(` }`);
|
|
335
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-plugin-exec-grant",
|
|
3
3
|
"name": "Exec Grant",
|
|
4
|
-
"description": "Time-boxed elevated shell access with
|
|
4
|
+
"description": "Time-boxed elevated shell access with admin approval via any messaging channel",
|
|
5
5
|
"version": "1.0.0",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"properties": {
|
|
9
|
-
"
|
|
9
|
+
"adminTarget": {
|
|
10
10
|
"type": "string",
|
|
11
|
-
"description": "E.164 phone
|
|
11
|
+
"description": "Admin contact: E.164 phone for WhatsApp/Signal, chat ID for Telegram, channel/user for Slack/Discord"
|
|
12
|
+
},
|
|
13
|
+
"channel": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"description": "Messaging channel to use for approval requests (e.g. whatsapp, telegram, slack, discord, signal)"
|
|
12
16
|
}
|
|
13
17
|
},
|
|
14
18
|
"additionalProperties": false
|
|
15
19
|
},
|
|
16
20
|
"uiHints": {
|
|
17
|
-
"
|
|
18
|
-
"label": "Admin
|
|
19
|
-
"placeholder": "+16505551234"
|
|
21
|
+
"adminTarget": {
|
|
22
|
+
"label": "Admin Target",
|
|
23
|
+
"placeholder": "+16505551234 or @admin or #approvals"
|
|
24
|
+
},
|
|
25
|
+
"channel": {
|
|
26
|
+
"label": "Channel",
|
|
27
|
+
"placeholder": "whatsapp"
|
|
20
28
|
}
|
|
21
29
|
},
|
|
22
30
|
"skills": ["skills/exec-grant"]
|
package/package.json
CHANGED
|
@@ -66,7 +66,7 @@ Grants auto-revoke via an OS-level timer. When revoked, security returns to allo
|
|
|
66
66
|
|
|
67
67
|
- **Never modify `~/.openclaw/exec-approvals.json` directly.** All changes go through grant.sh and revoke.sh.
|
|
68
68
|
- **Never attempt to cancel, extend, or tamper with the revocation timer.** The timer runs at the OS level and is intentionally outside agent control.
|
|
69
|
-
- **Never call grant.sh directly.** Only the plugin's `/grant` command handler (triggered by admin
|
|
69
|
+
- **Never call grant.sh directly.** Only the plugin's `/grant` command handler (triggered by admin reply) should invoke it.
|
|
70
70
|
- **Never request more time than needed.** Estimate conservatively and request a new grant if the first one runs out.
|
|
71
71
|
- **Always provide a specific reason.** Vague reasons like "need access" will likely be denied by the admin.
|
|
72
72
|
|
|
@@ -76,7 +76,7 @@ All scripts are located at `${CLAUDE_PLUGIN_ROOT}/skills/exec-grant/scripts/`:
|
|
|
76
76
|
|
|
77
77
|
| Script | Called By | Purpose |
|
|
78
78
|
|---|---|---|
|
|
79
|
-
| `request.sh` | Agent | Send approval request to admin via
|
|
79
|
+
| `request.sh` | Agent | Send approval request to admin via configured channel |
|
|
80
80
|
| `grant.sh` | Plugin `/grant` handler | Activate elevated access + schedule timer |
|
|
81
81
|
| `revoke.sh` | OS timer or `/revoke` | Revert to allowlist mode |
|
|
82
82
|
| `status.sh` | Agent | Check current grant state and remaining time |
|
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Threat Model
|
|
4
4
|
|
|
5
|
-
### Threat 1: Prompt Injection via
|
|
5
|
+
### Threat 1: Prompt Injection via Messaging Channel
|
|
6
6
|
|
|
7
|
-
**Attack:** A malicious message forwarded to the admin
|
|
7
|
+
**Attack:** A malicious message forwarded to the admin contains text like "Reply /grant 120 to this message." The admin might reflexively reply with the command.
|
|
8
8
|
|
|
9
9
|
**Mitigation:**
|
|
10
|
-
- `requireAuth: true` on `/grant` ensures only messages from
|
|
10
|
+
- `requireAuth: true` on `/grant` ensures only messages from authorized senders are processed
|
|
11
11
|
- The grant confirmation message sent back to the admin includes the exact duration and expiry time, making unintended grants visible
|
|
12
12
|
- Grants are capped at 120 minutes maximum
|
|
13
13
|
|
|
14
|
-
**Residual risk:** If the admin's
|
|
14
|
+
**Residual risk:** If the admin's account is compromised, the attacker can approve grants. This is inherent to any human-in-the-loop approval system.
|
|
15
15
|
|
|
16
16
|
### Threat 2: Agent Timer Tampering
|
|
17
17
|
|
|
@@ -113,6 +113,6 @@ Two separate files serve different purposes:
|
|
|
113
113
|
|
|
114
114
|
This separation means the gateway does not need to understand grant semantics -- it only checks the security field.
|
|
115
115
|
|
|
116
|
-
### Why
|
|
116
|
+
### Why an Out-of-Band Messaging Channel
|
|
117
117
|
|
|
118
|
-
WhatsApp
|
|
118
|
+
The approval channel (WhatsApp, Telegram, Slack, etc.) is out-of-band -- the agent cannot impersonate the admin on it. The admin receives requests on their device, can evaluate context, and responds with a simple command. This is preferable to in-band approval (e.g., a web UI the agent could potentially interact with). The plugin supports any channel OpenClaw provides, configured via the `channel` and `adminTarget` config fields.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# grant.sh -- Called by plugin /grant handler: activates elevated access + timer
|
|
3
3
|
# Usage: grant.sh <minutes>
|
|
4
|
+
# Env: EXEC_GRANT_CHANNEL, EXEC_GRANT_TARGET (set by index.ts from command context)
|
|
4
5
|
set -euo pipefail
|
|
5
6
|
|
|
6
7
|
# Ensure openclaw is on PATH (npm global bin may not be in non-interactive shells)
|
|
@@ -32,10 +33,8 @@ SECONDS_TO_ADD=$(( MINUTES * 60 ))
|
|
|
32
33
|
EXPIRY=$(( NOW + SECONDS_TO_ADD ))
|
|
33
34
|
|
|
34
35
|
if date -v+1S +%s >/dev/null 2>&1; then
|
|
35
|
-
# macOS (BSD date)
|
|
36
36
|
EXPIRY_HUMAN=$(date -r "$EXPIRY" '+%Y-%m-%d %H:%M:%S %Z')
|
|
37
37
|
else
|
|
38
|
-
# Linux (GNU date)
|
|
39
38
|
EXPIRY_HUMAN=$(date -d "@${EXPIRY}" '+%Y-%m-%d %H:%M:%S %Z')
|
|
40
39
|
fi
|
|
41
40
|
|
|
@@ -54,7 +53,6 @@ if [[ ! -f "$APPROVALS_FILE" ]]; then
|
|
|
54
53
|
ENDJSON
|
|
55
54
|
fi
|
|
56
55
|
|
|
57
|
-
# Targeted jq edit: only change .agents.main.security
|
|
58
56
|
TEMP_FILE=$(mktemp)
|
|
59
57
|
jq '.agents.main.security = "full"' "$APPROVALS_FILE" > "$TEMP_FILE" && mv "$TEMP_FILE" "$APPROVALS_FILE"
|
|
60
58
|
|
|
@@ -63,7 +61,6 @@ REVOKE_SCRIPT="${SCRIPT_DIR}/revoke.sh"
|
|
|
63
61
|
TIMER_ID=""
|
|
64
62
|
|
|
65
63
|
if command -v systemctl >/dev/null 2>&1 && systemctl --user status >/dev/null 2>&1; then
|
|
66
|
-
# Linux: systemd-run (tamper-resistant -- agent cannot cancel)
|
|
67
64
|
systemd-run --user \
|
|
68
65
|
--on-active="${MINUTES}m" \
|
|
69
66
|
--unit=exec-grant-revoke \
|
|
@@ -72,52 +69,13 @@ if command -v systemctl >/dev/null 2>&1 && systemctl --user status >/dev/null 2>
|
|
|
72
69
|
fi
|
|
73
70
|
|
|
74
71
|
if [[ -z "$TIMER_ID" ]] && [[ "$(uname)" == "Darwin" ]]; then
|
|
75
|
-
# macOS: launchd plist
|
|
76
|
-
PLIST_LABEL="com.openclaw.exec-grant-revoke"
|
|
77
|
-
PLIST_PATH="$HOME/Library/LaunchAgents/${PLIST_LABEL}.plist"
|
|
78
|
-
mkdir -p "$HOME/Library/LaunchAgents"
|
|
79
|
-
|
|
80
|
-
cat > "$PLIST_PATH" <<PLIST
|
|
81
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
82
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
83
|
-
<plist version="1.0">
|
|
84
|
-
<dict>
|
|
85
|
-
<key>Label</key>
|
|
86
|
-
<string>${PLIST_LABEL}</string>
|
|
87
|
-
<key>ProgramArguments</key>
|
|
88
|
-
<array>
|
|
89
|
-
<string>/bin/bash</string>
|
|
90
|
-
<string>${REVOKE_SCRIPT}</string>
|
|
91
|
-
</array>
|
|
92
|
-
<key>StartInterval</key>
|
|
93
|
-
<integer>0</integer>
|
|
94
|
-
<key>LaunchOnlyOnce</key>
|
|
95
|
-
<true/>
|
|
96
|
-
<key>RunAtLoad</key>
|
|
97
|
-
<false/>
|
|
98
|
-
<key>StandardOutPath</key>
|
|
99
|
-
<string>${HOME}/.openclaw/exec-grant-revoke.log</string>
|
|
100
|
-
<key>StandardErrorPath</key>
|
|
101
|
-
<string>${HOME}/.openclaw/exec-grant-revoke.log</string>
|
|
102
|
-
</dict>
|
|
103
|
-
</plist>
|
|
104
|
-
PLIST
|
|
105
|
-
|
|
106
|
-
# Use a delayed start: unload any existing, then schedule via at-style workaround
|
|
107
|
-
launchctl unload "$PLIST_PATH" 2>/dev/null || true
|
|
108
|
-
|
|
109
|
-
# launchd doesn't have on-active like systemd; use sleep-based subprocess
|
|
110
72
|
(sleep "$SECONDS_TO_ADD" && bash "$REVOKE_SCRIPT") &
|
|
111
73
|
BG_PID=$!
|
|
112
74
|
disown "$BG_PID" 2>/dev/null || true
|
|
113
75
|
TIMER_ID="bg:${BG_PID}"
|
|
114
|
-
|
|
115
|
-
# Clean up the plist since we're using bg approach
|
|
116
|
-
rm -f "$PLIST_PATH"
|
|
117
76
|
fi
|
|
118
77
|
|
|
119
78
|
if [[ -z "$TIMER_ID" ]]; then
|
|
120
|
-
# Fallback: background sleep + revoke
|
|
121
79
|
(sleep "$SECONDS_TO_ADD" && bash "$REVOKE_SCRIPT") &
|
|
122
80
|
BG_PID=$!
|
|
123
81
|
disown "$BG_PID" 2>/dev/null || true
|
|
@@ -139,12 +97,13 @@ ENDJSON
|
|
|
139
97
|
# --- Audit log ---
|
|
140
98
|
echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] GRANT: ${MINUTES}m, expires ${EXPIRY_HUMAN}, timer=${TIMER_ID}" >> "$AUDIT_LOG"
|
|
141
99
|
|
|
142
|
-
# --- Notify admin ---
|
|
143
|
-
|
|
100
|
+
# --- Notify admin (uses channel context from the /grant command) ---
|
|
101
|
+
NOTIFY_CHANNEL="${EXEC_GRANT_CHANNEL:-}"
|
|
102
|
+
NOTIFY_TARGET="${EXEC_GRANT_TARGET:-}"
|
|
144
103
|
|
|
145
|
-
if [[ -n "$
|
|
104
|
+
if [[ -n "$NOTIFY_TARGET" ]] && [[ -n "$NOTIFY_CHANNEL" ]]; then
|
|
146
105
|
MSG="Grant active for *${MINUTES}m*. Expires at ${EXPIRY_HUMAN}. Reply \`/revoke\` to end early."
|
|
147
|
-
openclaw message send --target "$
|
|
106
|
+
openclaw message send --target "$NOTIFY_TARGET" --channel "$NOTIFY_CHANNEL" --message "$MSG" 2>/dev/null || true
|
|
148
107
|
fi
|
|
149
108
|
|
|
150
109
|
echo "Grant activated: ${MINUTES}m of full shell access. Expires at ${EXPIRY_HUMAN}."
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# request.sh -- Agent-facing: sends
|
|
2
|
+
# request.sh -- Agent-facing: sends approval request to admin
|
|
3
3
|
# Usage: request.sh <minutes> "<reason>"
|
|
4
4
|
set -euo pipefail
|
|
5
5
|
|
|
@@ -10,6 +10,7 @@ done
|
|
|
10
10
|
|
|
11
11
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
12
12
|
STATE_FILE="$HOME/.openclaw/exec-grant-state.json"
|
|
13
|
+
PLUGIN_ID="openclaw-plugin-exec-grant"
|
|
13
14
|
|
|
14
15
|
# --- Arg validation ---
|
|
15
16
|
if [[ $# -lt 2 ]]; then
|
|
@@ -43,7 +44,6 @@ if [[ -f "$STATE_FILE" ]]; then
|
|
|
43
44
|
echo "Error: grant already active with ${REMAINING}m remaining. Use status.sh to check." >&2
|
|
44
45
|
exit 1
|
|
45
46
|
fi
|
|
46
|
-
# Expired but not cleaned up -- status.sh will self-heal
|
|
47
47
|
fi
|
|
48
48
|
|
|
49
49
|
if [[ "$STATUS" == "pending" ]]; then
|
|
@@ -51,22 +51,27 @@ if [[ -f "$STATE_FILE" ]]; then
|
|
|
51
51
|
NOW=$(date +%s)
|
|
52
52
|
ELAPSED=$(( NOW - REQUESTED_AT ))
|
|
53
53
|
if [[ "$ELAPSED" -lt 300 ]]; then
|
|
54
|
-
echo "Error: approval request already pending (sent $
|
|
54
|
+
echo "Error: approval request already pending (sent ${ELAPSED}s ago). Wait for admin response." >&2
|
|
55
55
|
exit 1
|
|
56
56
|
fi
|
|
57
|
-
# Stale pending request (>5m) -- allow re-request
|
|
58
57
|
fi
|
|
59
58
|
fi
|
|
60
59
|
|
|
61
|
-
# ---
|
|
62
|
-
|
|
60
|
+
# --- Read plugin config ---
|
|
61
|
+
ADMIN_TARGET=$(openclaw config get "plugins.entries.${PLUGIN_ID}.config.adminTarget" 2>/dev/null | tail -1 | tr -d '[:space:]' || true)
|
|
62
|
+
CHANNEL=$(openclaw config get "plugins.entries.${PLUGIN_ID}.config.channel" 2>/dev/null | tail -1 | tr -d '[:space:]' || true)
|
|
63
63
|
|
|
64
|
-
if [[ -z "$
|
|
65
|
-
echo "Error: no admin
|
|
64
|
+
if [[ -z "$ADMIN_TARGET" ]]; then
|
|
65
|
+
echo "Error: no admin target configured. Set plugins.entries.${PLUGIN_ID}.config.adminTarget" >&2
|
|
66
66
|
exit 1
|
|
67
67
|
fi
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
if [[ -z "$CHANNEL" ]]; then
|
|
70
|
+
echo "Error: no channel configured. Set plugins.entries.${PLUGIN_ID}.config.channel" >&2
|
|
71
|
+
exit 1
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
# --- Send approval request ---
|
|
70
75
|
MSG="*Elevated Access Request*
|
|
71
76
|
|
|
72
77
|
Agent requests *${MINUTES}m* of full shell access.
|
|
@@ -75,7 +80,7 @@ Agent requests *${MINUTES}m* of full shell access.
|
|
|
75
80
|
|
|
76
81
|
Reply \`/grant ${MINUTES}\` to approve or ignore to deny."
|
|
77
82
|
|
|
78
|
-
openclaw message send --target "$
|
|
83
|
+
openclaw message send --target "$ADMIN_TARGET" --channel "$CHANNEL" --message "$MSG"
|
|
79
84
|
|
|
80
85
|
# --- Write pending state ---
|
|
81
86
|
mkdir -p "$(dirname "$STATE_FILE")"
|
|
@@ -87,8 +92,9 @@ cat > "$STATE_FILE" <<ENDJSON
|
|
|
87
92
|
"requested_minutes": ${MINUTES},
|
|
88
93
|
"reason": $(jq -n --arg v "$REASON" '$v'),
|
|
89
94
|
"requested_at": ${NOW},
|
|
90
|
-
"
|
|
95
|
+
"admin_target": $(jq -n --arg v "$ADMIN_TARGET" '$v'),
|
|
96
|
+
"channel": $(jq -n --arg v "$CHANNEL" '$v')
|
|
91
97
|
}
|
|
92
98
|
ENDJSON
|
|
93
99
|
|
|
94
|
-
echo "Approval request sent to admin. Waiting for /grant ${MINUTES} reply."
|
|
100
|
+
echo "Approval request sent to admin via ${CHANNEL}. Waiting for /grant ${MINUTES} reply."
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# revoke.sh -- Called by timer or /revoke: reverts to allowlist mode
|
|
3
3
|
# Usage: revoke.sh
|
|
4
|
+
# Env: EXEC_GRANT_CHANNEL, EXEC_GRANT_TARGET (set by index.ts when called via /revoke)
|
|
4
5
|
set -euo pipefail
|
|
5
6
|
|
|
6
7
|
# Ensure openclaw is on PATH (npm global bin may not be in non-interactive shells)
|
|
@@ -8,6 +9,7 @@ for p in "$HOME/.npm-global/bin" "$HOME/.local/bin" "$HOME/node_modules/.bin"; d
|
|
|
8
9
|
[[ -d "$p" ]] && export PATH="$p:$PATH"
|
|
9
10
|
done
|
|
10
11
|
|
|
12
|
+
PLUGIN_ID="openclaw-plugin-exec-grant"
|
|
11
13
|
STATE_FILE="$HOME/.openclaw/exec-grant-state.json"
|
|
12
14
|
APPROVALS_FILE="$HOME/.openclaw/exec-approvals.json"
|
|
13
15
|
AUDIT_LOG="$HOME/.openclaw/exec-grant-audit.log"
|
|
@@ -29,8 +31,6 @@ if [[ -f "$STATE_FILE" ]]; then
|
|
|
29
31
|
systemctl --user stop "${UNIT}.timer" 2>/dev/null || true
|
|
30
32
|
systemctl --user stop "${UNIT}.service" 2>/dev/null || true
|
|
31
33
|
fi
|
|
32
|
-
# bg: timers cannot be reliably cancelled cross-process, but the revoke
|
|
33
|
-
# itself is idempotent so a second invocation is harmless
|
|
34
34
|
fi
|
|
35
35
|
|
|
36
36
|
# --- Update state file ---
|
|
@@ -46,10 +46,18 @@ ENDJSON
|
|
|
46
46
|
echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] REVOKE: security restored to allowlist" >> "$AUDIT_LOG"
|
|
47
47
|
|
|
48
48
|
# --- Notify admin ---
|
|
49
|
-
|
|
49
|
+
# When called from /revoke command, env vars are set by index.ts
|
|
50
|
+
# When called from timer, fall back to plugin config
|
|
51
|
+
NOTIFY_CHANNEL="${EXEC_GRANT_CHANNEL:-}"
|
|
52
|
+
NOTIFY_TARGET="${EXEC_GRANT_TARGET:-}"
|
|
53
|
+
|
|
54
|
+
if [[ -z "$NOTIFY_TARGET" ]] || [[ -z "$NOTIFY_CHANNEL" ]]; then
|
|
55
|
+
NOTIFY_TARGET=$(openclaw config get "plugins.entries.${PLUGIN_ID}.config.adminTarget" 2>/dev/null | tail -1 | tr -d '[:space:]' || true)
|
|
56
|
+
NOTIFY_CHANNEL=$(openclaw config get "plugins.entries.${PLUGIN_ID}.config.channel" 2>/dev/null | tail -1 | tr -d '[:space:]' || true)
|
|
57
|
+
fi
|
|
50
58
|
|
|
51
|
-
if [[ -n "$
|
|
52
|
-
openclaw message send --target "$
|
|
59
|
+
if [[ -n "$NOTIFY_TARGET" ]] && [[ -n "$NOTIFY_CHANNEL" ]]; then
|
|
60
|
+
openclaw message send --target "$NOTIFY_TARGET" --channel "$NOTIFY_CHANNEL" --message "Grant revoked. Security restored to allowlist mode." 2>/dev/null || true
|
|
53
61
|
fi
|
|
54
62
|
|
|
55
63
|
echo "Grant revoked. Security restored to allowlist mode."
|