From 776a4b1c7dda03ca8e0b7f300f954367b079b0f4 Mon Sep 17 00:00:00 2001 From: Karol Zalewski Date: Tue, 30 Jun 2026 07:46:46 +0200 Subject: [PATCH] fix(openclaw): handle exit code 3 from rtk rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interpret all RTK rewrite exit codes and respond appropriately: - Exit 0 (Allow): auto-apply rewrite - Exit 1 (Passthrough): no RTK equivalent, pass through unchanged - Exit 2 (Deny): block the call entirely - Exit 3 (Ask): rewrite available, require user approval via requireApproval with allow-once/deny decisions Uses execFileSync (not execSync) to avoid shell injection. Returns a [string | null, RewriteVerdict?] tuple from tryRewrite so the before_tool_call hook can react to each verdict type. "allow-always" omitted from allowedDecisions because OpenClaw does not auto-persist approval for plugin hooks — see: https://docs.openclaw.ai/plugins/plugin-permission-requests#troubleshooting Closes #2202 --- openclaw/index.ts | 95 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 7 deletions(-) diff --git a/openclaw/index.ts b/openclaw/index.ts index 4babe14d8b..8959073f7e 100644 --- a/openclaw/index.ts +++ b/openclaw/index.ts @@ -7,6 +7,14 @@ * All rewrite logic lives in `rtk rewrite` (src/discover/registry.rs). * This plugin is a thin delegate — to add or change rules, edit the * Rust registry, not this file. + * + * Exit code protocol for `rtk rewrite`: + * 0 + stdout Allow — rewrite found, explicitly allowed → auto-apply + * 1 No RTK equivalent → pass through unchanged + * 2 Deny rule matched → block the call + * 3 + stdout Ask rule matched (or default) → rewrite, require approval + * + * See: src/hooks/rewrite_cmd.rs */ import { execFileSync } from "node:child_process"; @@ -24,7 +32,20 @@ function checkRtk(): boolean { return rtkAvailable; } -function tryRewrite(command: string): string | null { +/** + * Delegate to `rtk rewrite` and interpret the exit code. + * + * Returns a tuple `[rewritten, verdict?]`: + * [string] — rewrite, auto-apply (exit 0) + * [string, "ask"] — rewrite, require user approval (exit 3) + * [null, "deny"] — command matched a deny rule (exit 2) + * [null] — no rewrite / passthrough (exit 1 or no change) + */ +type RewriteVerdict = "ask" | "deny"; + +function tryRewrite( + command: string +): [string | null, RewriteVerdict?] { try { const result = execFileSync("rtk", ["rewrite", command], { encoding: "utf-8", @@ -32,9 +53,22 @@ function tryRewrite(command: string): string | null { }) .toString() .trim(); - return result && result !== command ? result : null; - } catch { - return null; + // Exit 0 — Allow: rewrite and auto-apply + return [result && result !== command ? result : null]; + } catch (e: any) { + // Exit 3 — Ask: rewrite available but user must approve + if (e?.status === 3 && e.stdout) { + const result = e.stdout.toString().trim(); + if (result && result !== command) return [result, "ask"]; + // Exit 3 but no usable stdout — treat as passthrough + return [null]; + } + // Exit 2 — Deny: command matched a deny rule, block the call + if (e?.status === 2) { + return [null, "deny"]; + } + // Exit 1 or unknown — no rewrite, pass through + return [null]; } } @@ -58,14 +92,61 @@ export default function register(api: any) { const command = event.params?.command; if (typeof command !== "string") return; - const rewritten = tryRewrite(command); + const [rewritten, verdict] = tryRewrite(command); + + // Deny rule matched — block the call entirely + if (verdict === "deny") { + if (verbose) { + console.log(`[rtk] DENY: ${command}`); + } + return { + block: true, + blockReason: "RTK deny rule matched", + }; + } + if (!rewritten) return; if (verbose) { - console.log(`[rtk] ${command} -> ${rewritten}`); + console.log( + `[rtk] ${command} -> ${rewritten}${verdict === "ask" ? " (approval required)" : ""}` + ); + } + + const result: { + params: Record; + requireApproval?: { + title: string; + description: string; + severity: "info"; + timeoutBehavior: "deny"; + allowedDecisions: Array<"allow-once" | "deny">; + onResolution?: (decision: string) => void; + }; + } = { + params: { ...event.params, command: rewritten }, + }; + + // Exit 3 — Ask: rewrite but require user approval + if (verdict === "ask") { + result.requireApproval = { + title: "RTK rewrite suggestion", + description: `Rewrite: \`${command}\` → \`${rewritten}\``, + severity: "info", + timeoutBehavior: "deny", + // "allow-always" omitted: OpenClaw does not auto-persist approval + // for plugin hooks — see: + // https://docs.openclaw.ai/plugins/plugin-permission-requests#troubleshooting + allowedDecisions: ["allow-once", "deny"], + onResolution: (decision: string) => { + if (verbose) { + console.log(`[rtk] approval ${decision}: ${command} -> ${rewritten}`); + } + }, + }; } - return { params: { ...event.params, command: rewritten } }; + return result; }, { priority: 10 } );