openclaw-plugin-exec-grant 1.0.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 +57 -0
- package/openclaw.plugin.json +24 -0
- package/package.json +30 -0
- package/skills/exec-grant/SKILL.md +88 -0
- package/skills/exec-grant/references/security-model.md +118 -0
- package/skills/exec-grant/scripts/grant.sh +150 -0
- package/skills/exec-grant/scripts/request.sh +94 -0
- package/skills/exec-grant/scripts/revoke.sh +55 -0
- package/skills/exec-grant/scripts/status.sh +71 -0
package/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const SCRIPTS_DIR = join(__dirname, "skills", "exec-grant", "scripts");
|
|
5
|
+
|
|
6
|
+
function runScript(name: string, args: string[] = []): string {
|
|
7
|
+
const scriptPath = join(SCRIPTS_DIR, name);
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync("bash", [scriptPath, ...args], {
|
|
10
|
+
encoding: "utf-8",
|
|
11
|
+
timeout: 30_000,
|
|
12
|
+
}).trim();
|
|
13
|
+
} catch (err: any) {
|
|
14
|
+
const stderr = err.stderr?.toString().trim() || "";
|
|
15
|
+
const stdout = err.stdout?.toString().trim() || "";
|
|
16
|
+
return stderr || stdout || `Script ${name} failed with exit code ${err.status}`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function register(api: any) {
|
|
21
|
+
api.registerCommand({
|
|
22
|
+
name: "grant",
|
|
23
|
+
description: "Activate time-boxed elevated shell access (admin only)",
|
|
24
|
+
acceptsArgs: true,
|
|
25
|
+
requireAuth: true,
|
|
26
|
+
handler(ctx: any) {
|
|
27
|
+
const minutes = ctx.args?.trim();
|
|
28
|
+
if (!minutes || !/^\d+$/.test(minutes)) {
|
|
29
|
+
return { text: "Usage: /grant <minutes> (1-120)" };
|
|
30
|
+
}
|
|
31
|
+
const result = runScript("grant.sh", [minutes]);
|
|
32
|
+
return { text: result };
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
api.registerCommand({
|
|
37
|
+
name: "revoke",
|
|
38
|
+
description: "Revoke elevated shell access immediately (admin only)",
|
|
39
|
+
acceptsArgs: false,
|
|
40
|
+
requireAuth: true,
|
|
41
|
+
handler() {
|
|
42
|
+
const result = runScript("revoke.sh");
|
|
43
|
+
return { text: result };
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
api.registerCommand({
|
|
48
|
+
name: "grant-status",
|
|
49
|
+
description: "Check current grant state and remaining time",
|
|
50
|
+
acceptsArgs: false,
|
|
51
|
+
requireAuth: false,
|
|
52
|
+
handler() {
|
|
53
|
+
const result = runScript("status.sh");
|
|
54
|
+
return { text: result };
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "exec-grant",
|
|
3
|
+
"name": "Exec Grant",
|
|
4
|
+
"description": "Time-boxed elevated shell access with WhatsApp admin approval",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"configSchema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"adminPhone": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "E.164 phone number of the admin who approves grants"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"required": ["adminPhone"],
|
|
15
|
+
"additionalProperties": false
|
|
16
|
+
},
|
|
17
|
+
"uiHints": {
|
|
18
|
+
"adminPhone": {
|
|
19
|
+
"label": "Admin Phone (E.164)",
|
|
20
|
+
"placeholder": "+16505551234"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"skills": ["skills/exec-grant"]
|
|
24
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-plugin-exec-grant",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Time-boxed elevated shell access with WhatsApp admin approval for OpenClaw",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"openclaw",
|
|
9
|
+
"plugin",
|
|
10
|
+
"security",
|
|
11
|
+
"exec",
|
|
12
|
+
"grant",
|
|
13
|
+
"whatsapp"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"index.ts",
|
|
17
|
+
"openclaw.plugin.json",
|
|
18
|
+
"skills/"
|
|
19
|
+
],
|
|
20
|
+
"openclaw": {
|
|
21
|
+
"extensions": ["./index.ts"],
|
|
22
|
+
"install": {
|
|
23
|
+
"npmSpec": "openclaw-plugin-exec-grant",
|
|
24
|
+
"defaultChoice": "npm"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"openclaw": ">=2026.0.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: exec-grant
|
|
3
|
+
description: This skill should be used when a shell command is blocked by allowlist security, when the agent needs elevated access to run commands, when the agent encounters "permission denied" or "command not allowed" errors, or when the agent needs to request temporary full shell access from the admin.
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Exec Grant -- Time-Boxed Elevated Shell Access
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
This skill provides the workflow for requesting, monitoring, and working within time-boxed elevated shell access grants. When operating under allowlist security mode, certain commands will be blocked. Rather than failing silently or attempting workarounds, request explicit admin approval for a time-limited window of full shell access.
|
|
12
|
+
|
|
13
|
+
## When to Use
|
|
14
|
+
|
|
15
|
+
Activate this skill when:
|
|
16
|
+
- A shell command fails due to allowlist restrictions
|
|
17
|
+
- A task requires commands not on the current allowlist
|
|
18
|
+
- The agent needs temporary elevated privileges to complete work
|
|
19
|
+
|
|
20
|
+
## Core Workflow
|
|
21
|
+
|
|
22
|
+
### 1. Request Elevated Access
|
|
23
|
+
|
|
24
|
+
When a command is blocked, estimate how much time the task requires and call:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bash ${CLAUDE_PLUGIN_ROOT}/skills/exec-grant/scripts/request.sh <minutes> "<reason>"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- `minutes`: Time needed (1-120). Round up to the nearest 5-minute increment.
|
|
31
|
+
- `reason`: Clear, specific justification. Include the blocked command and what it accomplishes.
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
```bash
|
|
35
|
+
bash ${CLAUDE_PLUGIN_ROOT}/skills/exec-grant/scripts/request.sh 15 "Need to run docker compose and curl for API integration testing"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
After calling request.sh, **wait for admin approval**. Do not proceed with blocked commands until status shows "active".
|
|
39
|
+
|
|
40
|
+
### 2. Check Grant Status
|
|
41
|
+
|
|
42
|
+
To check whether a grant is active, pending, or expired:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bash ${CLAUDE_PLUGIN_ROOT}/skills/exec-grant/scripts/status.sh
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Possible states:
|
|
49
|
+
- **active** -- Full shell access is available. Output includes remaining time.
|
|
50
|
+
- **pending** -- Approval request sent, waiting for admin reply.
|
|
51
|
+
- **inactive** -- No grant. Commands are restricted to the allowlist.
|
|
52
|
+
|
|
53
|
+
### 3. Work Within the Grant Window
|
|
54
|
+
|
|
55
|
+
Once active, execute the required commands promptly. Monitor remaining time by calling status.sh periodically during long tasks. If the grant expires mid-task:
|
|
56
|
+
|
|
57
|
+
1. Stop executing privileged commands immediately
|
|
58
|
+
2. Assess remaining work
|
|
59
|
+
3. Request a **new** grant with justification for the additional time needed
|
|
60
|
+
|
|
61
|
+
### 4. Grant Expiration
|
|
62
|
+
|
|
63
|
+
Grants auto-revoke via an OS-level timer. When revoked, security returns to allowlist mode. There is no way to extend an active grant -- request a new one if more time is needed.
|
|
64
|
+
|
|
65
|
+
## Critical Rules
|
|
66
|
+
|
|
67
|
+
- **Never modify `~/.openclaw/exec-approvals.json` directly.** All changes go through grant.sh and revoke.sh.
|
|
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 WhatsApp reply) should invoke it.
|
|
70
|
+
- **Never request more time than needed.** Estimate conservatively and request a new grant if the first one runs out.
|
|
71
|
+
- **Always provide a specific reason.** Vague reasons like "need access" will likely be denied by the admin.
|
|
72
|
+
|
|
73
|
+
## Script Reference
|
|
74
|
+
|
|
75
|
+
All scripts are located at `${CLAUDE_PLUGIN_ROOT}/skills/exec-grant/scripts/`:
|
|
76
|
+
|
|
77
|
+
| Script | Called By | Purpose |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `request.sh` | Agent | Send approval request to admin via WhatsApp |
|
|
80
|
+
| `grant.sh` | Plugin `/grant` handler | Activate elevated access + schedule timer |
|
|
81
|
+
| `revoke.sh` | OS timer or `/revoke` | Revert to allowlist mode |
|
|
82
|
+
| `status.sh` | Agent | Check current grant state and remaining time |
|
|
83
|
+
|
|
84
|
+
## Additional Resources
|
|
85
|
+
|
|
86
|
+
### Reference Files
|
|
87
|
+
|
|
88
|
+
- **`references/security-model.md`** -- Threat model, recommended allowlist baseline, and design rationale for the grant system
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Security Model -- Exec Grant Plugin
|
|
2
|
+
|
|
3
|
+
## Threat Model
|
|
4
|
+
|
|
5
|
+
### Threat 1: Prompt Injection via WhatsApp
|
|
6
|
+
|
|
7
|
+
**Attack:** A malicious message forwarded to the admin's WhatsApp contains text like "Reply /grant 120 to this message." The admin might reflexively reply with the command.
|
|
8
|
+
|
|
9
|
+
**Mitigation:**
|
|
10
|
+
- `requireAuth: true` on `/grant` ensures only messages from the configured admin phone number are processed
|
|
11
|
+
- The grant confirmation message sent back to the admin includes the exact duration and expiry time, making unintended grants visible
|
|
12
|
+
- Grants are capped at 120 minutes maximum
|
|
13
|
+
|
|
14
|
+
**Residual risk:** If the admin's phone is compromised, the attacker can approve grants. This is inherent to any human-in-the-loop approval system.
|
|
15
|
+
|
|
16
|
+
### Threat 2: Agent Timer Tampering
|
|
17
|
+
|
|
18
|
+
**Attack:** A compromised or manipulated LLM attempts to cancel the revocation timer to maintain elevated access beyond the approved window.
|
|
19
|
+
|
|
20
|
+
**Mitigation:**
|
|
21
|
+
- On Linux, `systemd-run --user` creates a transient timer unit. The agent process does not have the credentials to cancel another user's systemd timers, and the timer runs independently of the gateway process.
|
|
22
|
+
- On macOS, the fallback uses a disowned background process. While theoretically killable if the PID is known, the agent skill explicitly prohibits timer manipulation.
|
|
23
|
+
- The state file records the expected expiry timestamp. `status.sh` performs self-healing: if the timestamp has passed but the state still says "active," it triggers an immediate revoke.
|
|
24
|
+
|
|
25
|
+
**Residual risk:** On macOS, the background sleep process is less tamper-resistant than systemd. A truly adversarial agent with unrestricted shell access could find and kill it. The self-healing check in status.sh provides a secondary safety net.
|
|
26
|
+
|
|
27
|
+
### Threat 3: Direct File Manipulation
|
|
28
|
+
|
|
29
|
+
**Attack:** The agent modifies `exec-approvals.json` directly, bypassing the grant/revoke workflow to escalate privileges permanently.
|
|
30
|
+
|
|
31
|
+
**Mitigation:**
|
|
32
|
+
- The SKILL.md explicitly instructs the agent never to modify exec-approvals.json directly
|
|
33
|
+
- The status.sh script cross-checks the state file against the actual approvals file and flags inconsistencies
|
|
34
|
+
- The audit log records all grant/revoke actions for post-incident review
|
|
35
|
+
|
|
36
|
+
**Residual risk:** An agent operating in "full" security mode could ignore skill instructions. This threat is mitigated by the allowlist baseline -- the agent cannot gain full access without admin approval in the first place.
|
|
37
|
+
|
|
38
|
+
### Threat 4: Grant Stacking / Extension
|
|
39
|
+
|
|
40
|
+
**Attack:** The agent requests a new grant before the current one expires, effectively creating indefinite elevated access.
|
|
41
|
+
|
|
42
|
+
**Mitigation:**
|
|
43
|
+
- request.sh checks for active grants and rejects requests if one is already running
|
|
44
|
+
- Each grant is a discrete time budget with no extension mechanism
|
|
45
|
+
- The admin sees each request individually and must make a conscious approval decision
|
|
46
|
+
|
|
47
|
+
### Threat 5: State File Corruption
|
|
48
|
+
|
|
49
|
+
**Attack:** The state file is deleted or corrupted, causing the system to lose track of an active grant.
|
|
50
|
+
|
|
51
|
+
**Mitigation:**
|
|
52
|
+
- revoke.sh operates on exec-approvals.json directly, not just the state file. Even if the state file is lost, the timer still fires and revokes access.
|
|
53
|
+
- status.sh detects inconsistencies between the state file and the actual approvals file
|
|
54
|
+
|
|
55
|
+
## Recommended Allowlist Baseline
|
|
56
|
+
|
|
57
|
+
For routine operations, the allowlist in `exec-approvals.json` should permit:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"agents": {
|
|
62
|
+
"main": {
|
|
63
|
+
"security": "allowlist",
|
|
64
|
+
"allowlist": [
|
|
65
|
+
"ls", "cat", "head", "tail", "wc", "grep", "find", "file",
|
|
66
|
+
"echo", "printf", "date", "whoami", "pwd",
|
|
67
|
+
"git status", "git log", "git diff", "git show",
|
|
68
|
+
"node --version", "npm --version", "python3 --version",
|
|
69
|
+
"openclaw config get", "openclaw message send",
|
|
70
|
+
"jq", "sed", "awk", "sort", "uniq", "tr", "cut"
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This baseline allows:
|
|
78
|
+
- **File inspection** -- reading files, listing directories, searching content
|
|
79
|
+
- **Git read operations** -- viewing history, diffs, and status (not push/pull/commit)
|
|
80
|
+
- **Version checks** -- verifying tool availability
|
|
81
|
+
- **OpenClaw operations** -- reading config and sending messages (needed by this plugin)
|
|
82
|
+
- **Text processing** -- jq, sed, awk for data manipulation
|
|
83
|
+
|
|
84
|
+
Commands that typically require a grant:
|
|
85
|
+
- `docker`, `docker compose` -- container management
|
|
86
|
+
- `curl`, `wget` -- network requests
|
|
87
|
+
- `npm install`, `pip install` -- package installation
|
|
88
|
+
- `git push`, `git commit` -- repository mutations
|
|
89
|
+
- `systemctl`, `service` -- service management
|
|
90
|
+
- `rm`, `mv`, `cp` -- file mutations
|
|
91
|
+
- `ssh`, `scp` -- remote access
|
|
92
|
+
- `sudo` -- privilege escalation
|
|
93
|
+
|
|
94
|
+
## Design Rationale
|
|
95
|
+
|
|
96
|
+
### Why OS-Level Timers
|
|
97
|
+
|
|
98
|
+
The revocation timer must be something the agent cannot prevent. If the timer ran inside the gateway process (e.g., `setTimeout`), a compromised agent could theoretically influence it. systemd-run creates an independent OS-level scheduled task that executes regardless of the gateway's state.
|
|
99
|
+
|
|
100
|
+
### Why Targeted jq Edits
|
|
101
|
+
|
|
102
|
+
On revoke, the script only sets `.agents.main.security = "allowlist"` rather than restoring a saved snapshot. This preserves any allowlist modifications the admin made during the grant window. A snapshot-restore approach would silently discard those changes.
|
|
103
|
+
|
|
104
|
+
### Why No Auto-Extension
|
|
105
|
+
|
|
106
|
+
Requiring a fresh approval for each time window prevents "approval fatigue creep" -- a pattern where automated extension requests become rubber-stamped. Each grant is a discrete, conscious decision by the admin.
|
|
107
|
+
|
|
108
|
+
### Why State File + Approvals File
|
|
109
|
+
|
|
110
|
+
Two separate files serve different purposes:
|
|
111
|
+
- `exec-approvals.json` is the source of truth for the gateway's security enforcement
|
|
112
|
+
- `exec-grant-state.json` tracks the grant lifecycle (pending/active/revoked) and provides metadata for the agent and admin
|
|
113
|
+
|
|
114
|
+
This separation means the gateway does not need to understand grant semantics -- it only checks the security field.
|
|
115
|
+
|
|
116
|
+
### Why WhatsApp as the Approval Channel
|
|
117
|
+
|
|
118
|
+
WhatsApp provides an out-of-band approval channel that the agent cannot impersonate. The admin receives requests on their phone, 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).
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# grant.sh -- Called by plugin /grant handler: activates elevated access + timer
|
|
3
|
+
# Usage: grant.sh <minutes>
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
# Ensure openclaw is on PATH (npm global bin may not be in non-interactive shells)
|
|
7
|
+
for p in "$HOME/.npm-global/bin" "$HOME/.local/bin" "$HOME/node_modules/.bin"; do
|
|
8
|
+
[[ -d "$p" ]] && export PATH="$p:$PATH"
|
|
9
|
+
done
|
|
10
|
+
|
|
11
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
+
STATE_FILE="$HOME/.openclaw/exec-grant-state.json"
|
|
13
|
+
APPROVALS_FILE="$HOME/.openclaw/exec-approvals.json"
|
|
14
|
+
AUDIT_LOG="$HOME/.openclaw/exec-grant-audit.log"
|
|
15
|
+
|
|
16
|
+
# --- Arg validation ---
|
|
17
|
+
if [[ $# -lt 1 ]]; then
|
|
18
|
+
echo "Usage: grant.sh <minutes>" >&2
|
|
19
|
+
exit 1
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
MINUTES="$1"
|
|
23
|
+
|
|
24
|
+
if ! [[ "$MINUTES" =~ ^[0-9]+$ ]] || [[ "$MINUTES" -lt 1 ]] || [[ "$MINUTES" -gt 120 ]]; then
|
|
25
|
+
echo "Error: minutes must be between 1 and 120" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
# --- Cross-platform date handling ---
|
|
30
|
+
NOW=$(date +%s)
|
|
31
|
+
SECONDS_TO_ADD=$(( MINUTES * 60 ))
|
|
32
|
+
EXPIRY=$(( NOW + SECONDS_TO_ADD ))
|
|
33
|
+
|
|
34
|
+
if date -v+1S +%s >/dev/null 2>&1; then
|
|
35
|
+
# macOS (BSD date)
|
|
36
|
+
EXPIRY_HUMAN=$(date -r "$EXPIRY" '+%Y-%m-%d %H:%M:%S %Z')
|
|
37
|
+
else
|
|
38
|
+
# Linux (GNU date)
|
|
39
|
+
EXPIRY_HUMAN=$(date -d "@${EXPIRY}" '+%Y-%m-%d %H:%M:%S %Z')
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# --- Modify exec-approvals.json: set security to "full" ---
|
|
43
|
+
mkdir -p "$(dirname "$APPROVALS_FILE")"
|
|
44
|
+
|
|
45
|
+
if [[ ! -f "$APPROVALS_FILE" ]]; then
|
|
46
|
+
cat > "$APPROVALS_FILE" <<'ENDJSON'
|
|
47
|
+
{
|
|
48
|
+
"agents": {
|
|
49
|
+
"main": {
|
|
50
|
+
"security": "allowlist"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
ENDJSON
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
# Targeted jq edit: only change .agents.main.security
|
|
58
|
+
TEMP_FILE=$(mktemp)
|
|
59
|
+
jq '.agents.main.security = "full"' "$APPROVALS_FILE" > "$TEMP_FILE" && mv "$TEMP_FILE" "$APPROVALS_FILE"
|
|
60
|
+
|
|
61
|
+
# --- Schedule auto-revoke timer ---
|
|
62
|
+
REVOKE_SCRIPT="${SCRIPT_DIR}/revoke.sh"
|
|
63
|
+
TIMER_ID=""
|
|
64
|
+
|
|
65
|
+
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
|
+
systemd-run --user \
|
|
68
|
+
--on-active="${MINUTES}m" \
|
|
69
|
+
--unit=exec-grant-revoke \
|
|
70
|
+
--description="Auto-revoke exec grant" \
|
|
71
|
+
-- bash "$REVOKE_SCRIPT" 2>/dev/null && TIMER_ID="systemd:exec-grant-revoke" || true
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
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
|
+
(sleep "$SECONDS_TO_ADD" && bash "$REVOKE_SCRIPT") &
|
|
111
|
+
BG_PID=$!
|
|
112
|
+
disown "$BG_PID" 2>/dev/null || true
|
|
113
|
+
TIMER_ID="bg:${BG_PID}"
|
|
114
|
+
|
|
115
|
+
# Clean up the plist since we're using bg approach
|
|
116
|
+
rm -f "$PLIST_PATH"
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
if [[ -z "$TIMER_ID" ]]; then
|
|
120
|
+
# Fallback: background sleep + revoke
|
|
121
|
+
(sleep "$SECONDS_TO_ADD" && bash "$REVOKE_SCRIPT") &
|
|
122
|
+
BG_PID=$!
|
|
123
|
+
disown "$BG_PID" 2>/dev/null || true
|
|
124
|
+
TIMER_ID="bg:${BG_PID}"
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
# --- Write active state ---
|
|
128
|
+
cat > "$STATE_FILE" <<ENDJSON
|
|
129
|
+
{
|
|
130
|
+
"status": "active",
|
|
131
|
+
"granted_minutes": ${MINUTES},
|
|
132
|
+
"granted_at": ${NOW},
|
|
133
|
+
"expiry": ${EXPIRY},
|
|
134
|
+
"expiry_human": $(jq -n --arg v "$EXPIRY_HUMAN" '$v'),
|
|
135
|
+
"timer_id": $(jq -n --arg v "$TIMER_ID" '$v')
|
|
136
|
+
}
|
|
137
|
+
ENDJSON
|
|
138
|
+
|
|
139
|
+
# --- Audit log ---
|
|
140
|
+
echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] GRANT: ${MINUTES}m, expires ${EXPIRY_HUMAN}, timer=${TIMER_ID}" >> "$AUDIT_LOG"
|
|
141
|
+
|
|
142
|
+
# --- Notify admin ---
|
|
143
|
+
ADMIN_PHONE=$(openclaw config get plugins.entries.exec-grant.config.adminPhone 2>/dev/null | tail -1 | tr -d '[:space:]' || true)
|
|
144
|
+
|
|
145
|
+
if [[ -n "$ADMIN_PHONE" ]]; then
|
|
146
|
+
MSG="Grant active for *${MINUTES}m*. Expires at ${EXPIRY_HUMAN}. Reply \`/revoke\` to end early."
|
|
147
|
+
openclaw message send --target "$ADMIN_PHONE" --channel whatsapp --message "$MSG" 2>/dev/null || true
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
echo "Grant activated: ${MINUTES}m of full shell access. Expires at ${EXPIRY_HUMAN}."
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# request.sh -- Agent-facing: sends WhatsApp approval request to admin
|
|
3
|
+
# Usage: request.sh <minutes> "<reason>"
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
# Ensure openclaw is on PATH (npm global bin may not be in non-interactive shells)
|
|
7
|
+
for p in "$HOME/.npm-global/bin" "$HOME/.local/bin" "$HOME/node_modules/.bin"; do
|
|
8
|
+
[[ -d "$p" ]] && export PATH="$p:$PATH"
|
|
9
|
+
done
|
|
10
|
+
|
|
11
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
+
STATE_FILE="$HOME/.openclaw/exec-grant-state.json"
|
|
13
|
+
|
|
14
|
+
# --- Arg validation ---
|
|
15
|
+
if [[ $# -lt 2 ]]; then
|
|
16
|
+
echo "Usage: request.sh <minutes> \"<reason>\"" >&2
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
MINUTES="$1"
|
|
21
|
+
shift
|
|
22
|
+
REASON="$*"
|
|
23
|
+
|
|
24
|
+
if ! [[ "$MINUTES" =~ ^[0-9]+$ ]] || [[ "$MINUTES" -lt 1 ]] || [[ "$MINUTES" -gt 120 ]]; then
|
|
25
|
+
echo "Error: minutes must be between 1 and 120" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
if [[ -z "$REASON" ]]; then
|
|
30
|
+
echo "Error: reason is required" >&2
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# --- Check for existing active grant ---
|
|
35
|
+
if [[ -f "$STATE_FILE" ]]; then
|
|
36
|
+
STATUS=$(jq -r '.status // "inactive"' "$STATE_FILE" 2>/dev/null || echo "inactive")
|
|
37
|
+
|
|
38
|
+
if [[ "$STATUS" == "active" ]]; then
|
|
39
|
+
EXPIRY=$(jq -r '.expiry // 0' "$STATE_FILE")
|
|
40
|
+
NOW=$(date +%s)
|
|
41
|
+
if [[ "$NOW" -lt "$EXPIRY" ]]; then
|
|
42
|
+
REMAINING=$(( (EXPIRY - NOW + 59) / 60 ))
|
|
43
|
+
echo "Error: grant already active with ${REMAINING}m remaining. Use status.sh to check." >&2
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
# Expired but not cleaned up -- status.sh will self-heal
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
if [[ "$STATUS" == "pending" ]]; then
|
|
50
|
+
REQUESTED_AT=$(jq -r '.requested_at // 0' "$STATE_FILE")
|
|
51
|
+
NOW=$(date +%s)
|
|
52
|
+
ELAPSED=$(( NOW - REQUESTED_AT ))
|
|
53
|
+
if [[ "$ELAPSED" -lt 300 ]]; then
|
|
54
|
+
echo "Error: approval request already pending (sent $(( ELAPSED ))s ago). Wait for admin response." >&2
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
# Stale pending request (>5m) -- allow re-request
|
|
58
|
+
fi
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
# --- Get admin phone ---
|
|
62
|
+
ADMIN_PHONE=$(openclaw config get plugins.entries.exec-grant.config.adminPhone 2>/dev/null | tail -1 | tr -d '[:space:]' || true)
|
|
63
|
+
|
|
64
|
+
if [[ -z "$ADMIN_PHONE" ]]; then
|
|
65
|
+
echo "Error: no admin phone configured. Set plugins.entries.exec-grant.config.adminPhone" >&2
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# --- Send WhatsApp approval request ---
|
|
70
|
+
MSG="*Elevated Access Request*
|
|
71
|
+
|
|
72
|
+
Agent requests *${MINUTES}m* of full shell access.
|
|
73
|
+
|
|
74
|
+
*Reason:* ${REASON}
|
|
75
|
+
|
|
76
|
+
Reply \`/grant ${MINUTES}\` to approve or ignore to deny."
|
|
77
|
+
|
|
78
|
+
openclaw message send --target "$ADMIN_PHONE" --channel whatsapp --message "$MSG"
|
|
79
|
+
|
|
80
|
+
# --- Write pending state ---
|
|
81
|
+
mkdir -p "$(dirname "$STATE_FILE")"
|
|
82
|
+
NOW=$(date +%s)
|
|
83
|
+
|
|
84
|
+
cat > "$STATE_FILE" <<ENDJSON
|
|
85
|
+
{
|
|
86
|
+
"status": "pending",
|
|
87
|
+
"requested_minutes": ${MINUTES},
|
|
88
|
+
"reason": $(jq -n --arg v "$REASON" '$v'),
|
|
89
|
+
"requested_at": ${NOW},
|
|
90
|
+
"admin_phone": $(jq -n --arg v "$ADMIN_PHONE" '$v')
|
|
91
|
+
}
|
|
92
|
+
ENDJSON
|
|
93
|
+
|
|
94
|
+
echo "Approval request sent to admin. Waiting for /grant ${MINUTES} reply."
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# revoke.sh -- Called by timer or /revoke: reverts to allowlist mode
|
|
3
|
+
# Usage: revoke.sh
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
# Ensure openclaw is on PATH (npm global bin may not be in non-interactive shells)
|
|
7
|
+
for p in "$HOME/.npm-global/bin" "$HOME/.local/bin" "$HOME/node_modules/.bin"; do
|
|
8
|
+
[[ -d "$p" ]] && export PATH="$p:$PATH"
|
|
9
|
+
done
|
|
10
|
+
|
|
11
|
+
STATE_FILE="$HOME/.openclaw/exec-grant-state.json"
|
|
12
|
+
APPROVALS_FILE="$HOME/.openclaw/exec-approvals.json"
|
|
13
|
+
AUDIT_LOG="$HOME/.openclaw/exec-grant-audit.log"
|
|
14
|
+
|
|
15
|
+
# --- Modify exec-approvals.json: set security back to "allowlist" ---
|
|
16
|
+
if [[ -f "$APPROVALS_FILE" ]]; then
|
|
17
|
+
TEMP_FILE=$(mktemp)
|
|
18
|
+
jq '.agents.main.security = "allowlist"' "$APPROVALS_FILE" > "$TEMP_FILE" && mv "$TEMP_FILE" "$APPROVALS_FILE"
|
|
19
|
+
else
|
|
20
|
+
echo "Warning: ${APPROVALS_FILE} not found, nothing to revoke" >&2
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
# --- Cancel active timers if called manually (idempotent) ---
|
|
24
|
+
if [[ -f "$STATE_FILE" ]]; then
|
|
25
|
+
TIMER_ID=$(jq -r '.timer_id // ""' "$STATE_FILE" 2>/dev/null || echo "")
|
|
26
|
+
|
|
27
|
+
if [[ "$TIMER_ID" == systemd:* ]]; then
|
|
28
|
+
UNIT="${TIMER_ID#systemd:}"
|
|
29
|
+
systemctl --user stop "${UNIT}.timer" 2>/dev/null || true
|
|
30
|
+
systemctl --user stop "${UNIT}.service" 2>/dev/null || true
|
|
31
|
+
fi
|
|
32
|
+
# bg: timers cannot be reliably cancelled cross-process, but the revoke
|
|
33
|
+
# itself is idempotent so a second invocation is harmless
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# --- Update state file ---
|
|
37
|
+
NOW=$(date +%s)
|
|
38
|
+
cat > "$STATE_FILE" <<ENDJSON
|
|
39
|
+
{
|
|
40
|
+
"status": "revoked",
|
|
41
|
+
"revoked_at": ${NOW}
|
|
42
|
+
}
|
|
43
|
+
ENDJSON
|
|
44
|
+
|
|
45
|
+
# --- Audit log ---
|
|
46
|
+
echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] REVOKE: security restored to allowlist" >> "$AUDIT_LOG"
|
|
47
|
+
|
|
48
|
+
# --- Notify admin ---
|
|
49
|
+
ADMIN_PHONE=$(openclaw config get plugins.entries.exec-grant.config.adminPhone 2>/dev/null | tail -1 | tr -d '[:space:]' || true)
|
|
50
|
+
|
|
51
|
+
if [[ -n "$ADMIN_PHONE" ]]; then
|
|
52
|
+
openclaw message send --target "$ADMIN_PHONE" --channel whatsapp --message "Grant revoked. Security restored to allowlist mode." 2>/dev/null || true
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
echo "Grant revoked. Security restored to allowlist mode."
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# status.sh -- Agent-facing: checks current grant state
|
|
3
|
+
# Usage: status.sh
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
7
|
+
STATE_FILE="$HOME/.openclaw/exec-grant-state.json"
|
|
8
|
+
APPROVALS_FILE="$HOME/.openclaw/exec-approvals.json"
|
|
9
|
+
|
|
10
|
+
# --- Read state file ---
|
|
11
|
+
if [[ ! -f "$STATE_FILE" ]]; then
|
|
12
|
+
echo "Status: inactive (no grant history)"
|
|
13
|
+
exit 0
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
STATUS=$(jq -r '.status // "inactive"' "$STATE_FILE" 2>/dev/null || echo "inactive")
|
|
17
|
+
NOW=$(date +%s)
|
|
18
|
+
|
|
19
|
+
case "$STATUS" in
|
|
20
|
+
active)
|
|
21
|
+
EXPIRY=$(jq -r '.expiry // 0' "$STATE_FILE")
|
|
22
|
+
|
|
23
|
+
if [[ "$NOW" -ge "$EXPIRY" ]]; then
|
|
24
|
+
# Self-heal: grant expired but timer didn't fire (or hasn't yet)
|
|
25
|
+
echo "Status: expired (auto-revoking...)"
|
|
26
|
+
bash "${SCRIPT_DIR}/revoke.sh"
|
|
27
|
+
exit 0
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
REMAINING_SECS=$(( EXPIRY - NOW ))
|
|
31
|
+
REMAINING_MINS=$(( (REMAINING_SECS + 59) / 60 ))
|
|
32
|
+
EXPIRY_HUMAN=$(jq -r '.expiry_human // "unknown"' "$STATE_FILE")
|
|
33
|
+
|
|
34
|
+
# Cross-check with actual approvals file
|
|
35
|
+
ACTUAL_SECURITY="unknown"
|
|
36
|
+
if [[ -f "$APPROVALS_FILE" ]]; then
|
|
37
|
+
ACTUAL_SECURITY=$(jq -r '.agents.main.security // "unknown"' "$APPROVALS_FILE" 2>/dev/null || echo "unknown")
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
if [[ "$ACTUAL_SECURITY" != "full" ]]; then
|
|
41
|
+
echo "Warning: state says active but exec-approvals.json shows '${ACTUAL_SECURITY}'"
|
|
42
|
+
echo "Status: inconsistent (run revoke.sh to reset)"
|
|
43
|
+
exit 1
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
echo "Status: active"
|
|
47
|
+
echo "Remaining: ${REMAINING_MINS}m (${REMAINING_SECS}s)"
|
|
48
|
+
echo "Expires: ${EXPIRY_HUMAN}"
|
|
49
|
+
;;
|
|
50
|
+
|
|
51
|
+
pending)
|
|
52
|
+
REQUESTED_AT=$(jq -r '.requested_at // 0' "$STATE_FILE")
|
|
53
|
+
ELAPSED=$(( NOW - REQUESTED_AT ))
|
|
54
|
+
MINUTES=$(jq -r '.requested_minutes // "?"' "$STATE_FILE")
|
|
55
|
+
REASON=$(jq -r '.reason // "unknown"' "$STATE_FILE")
|
|
56
|
+
|
|
57
|
+
echo "Status: pending"
|
|
58
|
+
echo "Requested: ${MINUTES}m (${ELAPSED}s ago)"
|
|
59
|
+
echo "Reason: ${REASON}"
|
|
60
|
+
;;
|
|
61
|
+
|
|
62
|
+
revoked)
|
|
63
|
+
REVOKED_AT=$(jq -r '.revoked_at // 0' "$STATE_FILE")
|
|
64
|
+
AGO=$(( NOW - REVOKED_AT ))
|
|
65
|
+
echo "Status: inactive (revoked ${AGO}s ago)"
|
|
66
|
+
;;
|
|
67
|
+
|
|
68
|
+
*)
|
|
69
|
+
echo "Status: inactive"
|
|
70
|
+
;;
|
|
71
|
+
esac
|