feat: make exec command highlighting optional

This commit is contained in:
jesse-merhi
2026-05-12 02:37:55 +10:00
committed by Jesse Merhi
parent 38de1a7518
commit 79c2ed9065
22 changed files with 194 additions and 10 deletions
+1
View File
@@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai
- Dependencies: refresh workspace pins and move the WhatsApp plugin from `@whiskeysockets/baileys` to `baileys` while keeping the `7.0.0-rc10` runtime.
- Plugin SDK: add bundled-plugin session actions, `sendSessionAttachment`, and Cron-backed `scheduleSessionTurn`/tag cleanup under the grouped session namespace. Replaces #75578/#75581/#75588 and part of #73384/#74483. Thanks @100yenadmin.
- Plugin SDK/media-understanding: add `extractStructuredWithModel(...)` plus the optional provider-side `extractStructured(...)` seam so trusted plugins can run bounded image-first structured extraction with optional supplemental text context through provider-owned runtimes such as Codex.
- Exec approvals: add `tools.exec.commandHighlighting` so parser-derived command highlighting in approval prompts can be enabled globally or per agent. (#79348) Thanks @jesse-merhi.
### Fixes
+2 -2
View File
@@ -1,4 +1,4 @@
2800c9a6377fd02cb55bbf2577d63f2acc7193ed89be6fcffd32439893eaabc1 config-baseline.json
ebbf966b41d99cd17282553f7882d1b826782599cbca6719b6f2c25b0e6b235d config-baseline.core.json
17b1ca7d3087be090b456b125d1e380b552e5bb4359132751f1a55590aba8fad config-baseline.json
3325af3a6292959bb38166e9136c638dce5d2093d2339076742890848088a972 config-baseline.core.json
222d0338d6ed290870cac70cdf5e390bc1bb60c4462e46f847003bafe25c5a6e config-baseline.channel.json
18f71e9d4a62fe68fbd5bf18d5833a4e380fc705ad641769e1cf05794286344c config-baseline.plugin.json
+1
View File
@@ -131,6 +131,7 @@ Controls elevated exec access outside the sandbox:
cleanupMs: 1800000,
notifyOnExit: true,
notifyOnExitEmptySuccess: false,
commandHighlighting: false,
applyPatch: {
enabled: false,
allowModels: ["gpt-5.5"],
+14
View File
@@ -166,6 +166,20 @@ In strict mode these commands still need explicit approval, and
`allow-always` does not persist new allowlist entries for them
automatically.
### `tools.exec.commandHighlighting`
<ParamField path="commandHighlighting" type="boolean" default="false">
Controls only presentation in exec approval prompts. When enabled,
OpenClaw may attach parser-derived command spans so Web approval
prompts can highlight command tokens. Set it to `true` to enable
command text highlighting.
</ParamField>
This setting does **not** change `security`, `ask`, allowlist matching,
strict inline-eval behavior, approval forwarding, or command execution.
It can be set globally under `tools.exec.commandHighlighting` or per
agent under `agents.list[].tools.exec.commandHighlighting`.
## YOLO mode (no-approval)
If you want host exec to run without approval prompts, you must open
+1
View File
@@ -109,6 +109,7 @@ Notes:
- In `security=full` plus `ask=off` mode, host exec follows the configured policy directly; there is no extra heuristic command-obfuscation prefilter or script-preflight rejection layer.
- `tools.exec.node` (default: unset)
- `tools.exec.strictInlineEval` (default: false): when true, inline interpreter eval forms such as `python -c`, `node -e`, `ruby -e`, `perl -e`, `php -r`, `lua -e`, and `osascript -e` always require explicit approval. `allow-always` can still persist benign interpreter/script invocations, but inline-eval forms still prompt each time.
- `tools.exec.commandHighlighting` (default: false): when true, approval prompts can highlight parser-derived command spans in the command text. Set to `true` globally or per agent to enable command text highlighting without changing exec approval policy.
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs (gateway + sandbox only).
- `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries. For behavior details, see [Safe bins](/tools/exec-approvals-advanced#safe-bins-stdin-only).
- `tools.exec.safeBinTrustedDirs`: additional explicit directories trusted for `safeBins` path checks. `PATH` entries are never auto-trusted. Built-in defaults are `/bin` and `/usr/bin`.
@@ -250,6 +250,7 @@ describe("requestExecApprovalDecision", () => {
await registerExecApprovalRequestForHost({
approvalId: "approval-id",
command: 'ls | grep "stuff" | python -c \'print("hi")\'',
commandHighlighting: true,
workdir: "/tmp/project",
host: "node",
security: "allowlist",
@@ -264,6 +265,47 @@ describe("requestExecApprovalDecision", () => {
expect(payload?.commandSpans).toContainEqual({ startIndex: 20, endIndex: 26 });
});
it("does not generate command spans by default", async () => {
vi.mocked(callGatewayTool).mockResolvedValue({ id: "approval-id", expiresAtMs: 1234 });
await registerExecApprovalRequestForHost({
approvalId: "approval-id",
command: 'ls | grep "stuff" | python -c \'print("hi")\'',
workdir: "/tmp/project",
host: "node",
security: "allowlist",
ask: "always",
});
expect(commandExplainerMock.explainShellCommand).not.toHaveBeenCalled();
expect(commandExplainerMock.formatCommandSpans).not.toHaveBeenCalled();
const payload = vi.mocked(callGatewayTool).mock.calls[0]?.[2] as
| { commandSpans?: unknown }
| undefined;
expect(payload?.commandSpans).toBeUndefined();
});
it("does not generate command spans when command highlighting is disabled", async () => {
vi.mocked(callGatewayTool).mockResolvedValue({ id: "approval-id", expiresAtMs: 1234 });
await registerExecApprovalRequestForHost({
approvalId: "approval-id",
command: 'ls | grep "stuff" | python -c \'print("hi")\'',
commandHighlighting: false,
workdir: "/tmp/project",
host: "node",
security: "allowlist",
ask: "always",
});
expect(commandExplainerMock.explainShellCommand).not.toHaveBeenCalled();
expect(commandExplainerMock.formatCommandSpans).not.toHaveBeenCalled();
const payload = vi.mocked(callGatewayTool).mock.calls[0]?.[2] as
| { commandSpans?: unknown }
| undefined;
expect(payload?.commandSpans).toBeUndefined();
});
it("uses system run plan command text for host approval explanations", async () => {
vi.mocked(callGatewayTool).mockResolvedValue({ id: "approval-id", expiresAtMs: 1234 });
@@ -276,6 +318,7 @@ describe("requestExecApprovalDecision", () => {
agentId: null,
sessionKey: null,
},
commandHighlighting: true,
workdir: "/tmp/project",
host: "node",
security: "allowlist",
@@ -362,6 +405,7 @@ describe("requestExecApprovalDecision", () => {
approvalId: "approval-id",
command: "echo hi",
commandSpans: [{ startIndex: 0, endIndex: 4 }],
commandHighlighting: true,
workdir: "/tmp/project",
host: "node",
security: "allowlist",
@@ -185,6 +185,7 @@ type HostExecApprovalParams = {
ask: ExecAsk;
warningText?: string;
commandSpans?: ExecApprovalCommandSpan[];
commandHighlighting?: boolean;
agentId?: string;
resolvedPath?: string;
sessionKey?: string;
@@ -269,10 +270,12 @@ async function buildHostApprovalDecisionParams(
params: HostExecApprovalParams,
): Promise<RequestExecApprovalDecisionParams> {
const commandSpans =
params.commandSpans ??
(shouldSkipGeneratedCommandSpans(params)
? undefined
: await resolveCommandSpans(params.command ?? params.systemRunPlan?.commandText));
params.commandHighlighting === true
? (params.commandSpans ??
(shouldSkipGeneratedCommandSpans(params)
? undefined
: await resolveCommandSpans(params.command ?? params.systemRunPlan?.commandText)))
: undefined;
return {
id: params.approvalId,
command: params.command,
@@ -60,6 +60,7 @@ export type ProcessGatewayAllowlistParams = {
safeBins: Set<string>;
safeBinProfiles: Readonly<Record<string, SafeBinProfile>>;
strictInlineEval?: boolean;
commandHighlighting?: boolean;
trigger?: string;
agentId?: string;
sessionKey?: string;
@@ -373,6 +374,7 @@ export async function processGatewayAllowlist(
host: "gateway",
security: hostSecurity,
ask: hostAsk,
commandHighlighting: params.commandHighlighting,
warningText: params.warnings.join("\n").trim() || undefined,
...buildExecApprovalRequesterContext({
agentId: params.agentId,
+1
View File
@@ -92,6 +92,7 @@ export async function executeNodeHostCommand(
nodeId: target.nodeId,
security: hostSecurity,
ask: hostAsk,
commandHighlighting: params.commandHighlighting,
...buildExecApprovalRequesterContext({
agentId: prepared.agentId,
sessionKey: prepared.sessionKey,
@@ -19,6 +19,7 @@ export type ExecuteNodeHostCommandParams = {
security: ExecSecurity;
ask: ExecAsk;
strictInlineEval?: boolean;
commandHighlighting?: boolean;
timeoutSec?: number;
defaultTimeoutSec: number;
approvalRunningNoticeMs: number;
+1
View File
@@ -14,6 +14,7 @@ export type ExecToolDefaults = {
pathPrepend?: string[];
safeBins?: string[];
strictInlineEval?: boolean;
commandHighlighting?: boolean;
safeBinTrustedDirs?: string[];
safeBinProfiles?: Record<string, SafeBinProfileFixture>;
agentId?: string;
+2
View File
@@ -1486,6 +1486,7 @@ export function createExecTool(
security,
ask,
strictInlineEval: defaults?.strictInlineEval,
commandHighlighting: defaults?.commandHighlighting,
trigger: defaults?.trigger,
timeoutSec: params.timeout,
defaultTimeoutSec,
@@ -1515,6 +1516,7 @@ export function createExecTool(
safeBins,
safeBinProfiles,
strictInlineEval: defaults?.strictInlineEval,
commandHighlighting: defaults?.commandHighlighting,
trigger: defaults?.trigger,
agentId,
sessionKey: defaults?.sessionKey,
+6
View File
@@ -1,6 +1,7 @@
import { createCodingTools, createReadTool } from "@earendil-works/pi-coding-agent";
import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js";
import { HEARTBEAT_RESPONSE_TOOL_NAME } from "../auto-reply/heartbeat-tool-response.js";
import { resolveExecCommandHighlighting } from "../config/exec-command-highlighting.js";
import type { ModelCompatConfig } from "../config/types.models.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js";
@@ -274,6 +275,10 @@ function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
strictInlineEval: agentExec?.strictInlineEval ?? globalExec?.strictInlineEval,
commandHighlighting: resolveExecCommandHighlighting({
config: cfg,
agentId: params.agentId,
}),
safeBinTrustedDirs: agentExec?.safeBinTrustedDirs ?? globalExec?.safeBinTrustedDirs,
safeBinProfiles: resolveMergedSafeBinProfileFixtures({
global: globalExec,
@@ -678,6 +683,7 @@ export function createOpenClawCodingTools(options?: {
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
safeBins: options?.exec?.safeBins ?? execConfig.safeBins,
strictInlineEval: options?.exec?.strictInlineEval ?? execConfig.strictInlineEval,
commandHighlighting: options?.exec?.commandHighlighting ?? execConfig.commandHighlighting,
safeBinTrustedDirs: options?.exec?.safeBinTrustedDirs ?? execConfig.safeBinTrustedDirs,
safeBinProfiles: options?.exec?.safeBinProfiles ?? execConfig.safeBinProfiles,
agentId,
+16
View File
@@ -0,0 +1,16 @@
import { normalizeAgentId } from "../routing/session-key.js";
import type { OpenClawConfig } from "./types.openclaw.js";
export function resolveExecCommandHighlighting(params: {
config?: OpenClawConfig | null;
agentId?: string | null;
}): boolean {
const config = params.config ?? {};
const globalValue = config.tools?.exec?.commandHighlighting;
const agentId = params.agentId ? normalizeAgentId(params.agentId) : null;
const agentValue = agentId
? config.agents?.list?.find((entry) => normalizeAgentId(entry.id) === agentId)?.tools?.exec
?.commandHighlighting
: undefined;
return agentValue ?? globalValue ?? false;
}
+1
View File
@@ -500,6 +500,7 @@ const TOOLS_HOOKS_TARGET_KEYS = [
"tools.byProvider",
"tools.exec.approvalRunningNoticeMs",
"tools.exec.strictInlineEval",
"tools.exec.commandHighlighting",
"tools.links.enabled",
"tools.links.maxLinks",
"tools.links.models",
+2
View File
@@ -700,6 +700,8 @@ export const FIELD_HELP: Record<string, string> = {
"Allow stdin-only safe binaries to run without explicit allowlist entries.",
"tools.exec.strictInlineEval":
"Require explicit approval for interpreter inline-eval forms such as `python -c`, `node -e`, `ruby -e`, or `osascript -e`. Prevents silent allowlist reuse and downgrades allow-always to ask-each-time for those forms.",
"tools.exec.commandHighlighting":
"Show parser-derived command highlights in exec approval prompts (default: false). Enable this to render highlighted command text without changing exec approval policy.",
"tools.exec.safeBinTrustedDirs":
"Additional explicit directories trusted for safe-bin path checks (PATH entries are never auto-trusted).",
"tools.exec.safeBinProfiles":
+1
View File
@@ -259,6 +259,7 @@ export const FIELD_LABELS: Record<string, string> = {
"tools.exec.pathPrepend": "Exec PATH Prepend",
"tools.exec.safeBins": "Exec Safe Bins",
"tools.exec.strictInlineEval": "Require Inline-Eval Approval",
"tools.exec.commandHighlighting": "Exec Command Highlighting",
"tools.exec.safeBinTrustedDirs": "Exec Safe Bin Trusted Dirs",
"tools.exec.safeBinProfiles": "Exec Safe Bin Profiles",
approvals: "Approvals",
+25
View File
@@ -329,6 +329,31 @@ describe("config schema", () => {
});
});
it("accepts exec command highlighting config in global and agent scopes", () => {
const tools = ToolsSchema.parse({
exec: {
commandHighlighting: false,
},
});
expect(tools?.exec?.commandHighlighting).toBe(false);
const config = OpenClawSchema.parse({
agents: {
list: [
{
id: "main",
tools: {
exec: {
commandHighlighting: false,
},
},
},
],
},
});
expect(config.agents?.list?.[0]?.tools?.exec?.commandHighlighting).toBe(false);
});
it("accepts experimental tool flags in the runtime zod schema", () => {
const parsed = ToolsSchema.parse({
experimental: {
+2
View File
@@ -284,6 +284,8 @@ export type ExecToolConfig = {
* Prevents silent allowlist reuse and allow-always persistence for those forms.
*/
strictInlineEval?: boolean;
/** Render parser-derived command highlights in exec approval prompts (default: false). */
commandHighlighting?: boolean;
/** Extra explicit directories trusted for safeBins path checks (never derived from PATH). */
safeBinTrustedDirs?: string[];
/** Optional custom safe-bin profiles for entries in tools.exec.safeBins. */
+1
View File
@@ -471,6 +471,7 @@ const ToolExecBaseShape = {
pathPrepend: z.array(z.string()).optional(),
safeBins: z.array(z.string()).optional(),
strictInlineEval: z.boolean().optional(),
commandHighlighting: z.boolean().optional(),
safeBinTrustedDirs: z.array(z.string()).optional(),
safeBinProfiles: z.record(z.string(), ToolExecSafeBinProfileSchema).optional(),
backgroundMs: z.number().int().positive().optional(),
+8 -1
View File
@@ -1,3 +1,4 @@
import { resolveExecCommandHighlighting } from "../../config/exec-command-highlighting.js";
import { resolveCommandAnalysisSummaryForDisplay } from "../../infra/command-analysis/explain.js";
import {
resolveExecApprovalCommandDisplay,
@@ -241,6 +242,12 @@ export function createExecApprovalHandlers(
}
const envBinding = buildSystemRunApprovalEnvBinding(p.env);
const warningText = normalizeOptionalString(p.warningText);
const runtimeConfig =
typeof context.getRuntimeConfig === "function" ? context.getRuntimeConfig() : {};
const commandHighlighting = resolveExecCommandHighlighting({
config: runtimeConfig,
agentId: effectiveAgentId,
});
const commandAnalysis = resolveCommandAnalysisSummaryForDisplay({
host,
commandText: effectiveCommandText,
@@ -250,7 +257,7 @@ export function createExecApprovalHandlers(
});
const sanitizedCommandText = sanitizeExecApprovalDisplayText(effectiveCommandText);
const commandSpans =
sanitizedCommandText === effectiveCommandText
commandHighlighting && sanitizedCommandText === effectiveCommandText
? normalizeCommandSpans(p.commandSpans, sanitizedCommandText.length)
: undefined;
const systemRunBinding =
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { emitAgentEvent } from "../../infra/agent-events.js";
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js";
import {
@@ -861,12 +862,13 @@ describe("exec approval handlers", () => {
});
}
function createExecApprovalFixture() {
function createExecApprovalFixture(opts?: { config?: OpenClawConfig }) {
const manager = new ExecApprovalManager();
const handlers = createExecApprovalHandlers(manager);
const broadcasts: Array<{ event: string; payload: unknown }> = [];
const respond = vi.fn();
const context = {
getRuntimeConfig: () => opts?.config ?? {},
broadcast: (event: string, payload: unknown) => {
broadcasts.push({ event, payload });
},
@@ -1476,7 +1478,9 @@ describe("exec approval handlers", () => {
});
it("preserves command analysis and normalizes command spans", async () => {
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
const { handlers, broadcasts, respond, context } = createExecApprovalFixture({
config: { tools: { exec: { commandHighlighting: true } } },
});
await requestExecApproval({
handlers,
respond,
@@ -1503,8 +1507,56 @@ describe("exec approval handlers", () => {
]);
});
it("drops command spans when command display sanitization changes offsets", async () => {
it("drops command spans by default", async () => {
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
await requestExecApproval({
handlers,
respond,
context,
params: {
timeoutMs: 10,
command: "ls | python -c 'print(1)'",
commandSpans: [
{ startIndex: 0, endIndex: 2 },
{ startIndex: 5, endIndex: 11 },
],
},
});
const { request } = getRequestedExecApprovalPayload(broadcasts);
expect(request["commandAnalysis"]).toEqual(
expect.objectContaining({ commandCount: 1, nestedCommandCount: 0 }),
);
expect(request["commandSpans"]).toBeUndefined();
});
it("drops command spans when command highlighting is disabled", async () => {
const { handlers, broadcasts, respond, context } = createExecApprovalFixture({
config: { tools: { exec: { commandHighlighting: false } } },
});
await requestExecApproval({
handlers,
respond,
context,
params: {
timeoutMs: 10,
command: "ls | python -c 'print(1)'",
commandSpans: [
{ startIndex: 0, endIndex: 2 },
{ startIndex: 5, endIndex: 11 },
],
},
});
const { request } = getRequestedExecApprovalPayload(broadcasts);
expect(request["commandAnalysis"]).toEqual(
expect.objectContaining({ commandCount: 1, nestedCommandCount: 0 }),
);
expect(request["commandSpans"]).toBeUndefined();
});
it("drops command spans when command display sanitization changes offsets", async () => {
const { handlers, broadcasts, respond, context } = createExecApprovalFixture({
config: { tools: { exec: { commandHighlighting: true } } },
});
await requestExecApproval({
handlers,
respond,