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 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