feat(approvals): typed approval scope summaries on channel cards (#130116)

* feat(approvals): typed approval scope summaries on channel cards

Approval owners can attach a closed ApprovalScope union (message-send,
payment, external-post) describing an action's blast radius. The gateway
sanitizes it once at the producer boundary, the core view model renders a
Scope metadata row so Slack/Discord/Google Chat cards show it unchanged,
shared text builders cover Telegram/WhatsApp/Signal/iMessage/Matrix, and
the durable presentation carries it additively for operator surfaces.
Scope is display-only, never authorization; missing scope keeps today's
cards.

* fix(approvals): emit native ApprovalScope union and clamp recipient previews

Name the three scope variants as registered protocol schemas so the Swift
generator emits the ApprovalScope discriminated union the presentation
structs reference, and commit the regenerated GatewayModels.swift. Clamp
recipient previews to the declared recipientCount at the sanitize boundary
so a count of 1 with 2 previews can no longer render inconsistently.
Addresses both ClawSweeper findings on #130116.

* refactor(approvals): extract text sanitizer to break the exec-approvals import cycle

check:architecture flagged approval-scope joining the exec-approvals SCC
through exec-approval-command-display. Move the self-contained display
sanitizer into a leaf module (exec-approval-text-sanitize) with no
exec-approvals imports and migrate all sanitize importers; command-display
keeps only the payload-typed command/preview resolver.

* chore(plugin-sdk): ratchet public surface budgets down after sanitizer extraction

The approval display sanitizers left the publicly reachable SDK graph when
they moved to the exec-approval-text-sanitize leaf: exports 4343 -> 4338,
callable exports 2582 -> 2578. Shrink-only budget pin.
This commit is contained in:
Peter Steinberger
2026-08-26 12:13:49 -07:00
committed by GitHub
parent 8a101ed5a5
commit 99a02bf115
44 changed files with 945 additions and 223 deletions
@@ -17619,6 +17619,71 @@ public struct TerminalExitEvent: Codable, Sendable {
}
}
public struct MessageSendApprovalScope: Codable, Sendable {
public let kind: String
public let target: String
public let recipientcount: Int
public let recipients: [String]?
public let audience: AnyCodable?
public init(
kind: String,
target: String,
recipientcount: Int,
recipients: [String]? = nil,
audience: AnyCodable? = nil)
{
self.kind = kind
self.target = target
self.recipientcount = recipientcount
self.recipients = recipients
self.audience = audience
}
private enum CodingKeys: String, CodingKey {
case kind
case target
case recipientcount = "recipientCount"
case recipients
case audience
}
}
public struct PaymentApprovalScope: Codable, Sendable {
public let kind: String
public let amount: String
public let currency: String
public let target: String
public init(
kind: String,
amount: String,
currency: String,
target: String)
{
self.kind = kind
self.amount = amount
self.currency = currency
self.target = target
}
}
public struct ExternalPostApprovalScope: Codable, Sendable {
public let kind: String
public let target: String
public let visibility: AnyCodable
public init(
kind: String,
target: String,
visibility: AnyCodable)
{
self.kind = kind
self.target = target
self.visibility = visibility
}
}
public struct ExecApprovalPresentation: Codable, Sendable {
public let kind: String
public let commandtext: String
@@ -17627,6 +17692,7 @@ public struct ExecApprovalPresentation: Codable, Sendable {
public let host: AnyCodable?
public let nodeid: AnyCodable?
public let agentid: AnyCodable?
public let scope: ApprovalScope?
public let alloweddecisions: [ApprovalDecision]
public init(
@@ -17637,6 +17703,7 @@ public struct ExecApprovalPresentation: Codable, Sendable {
host: AnyCodable? = nil,
nodeid: AnyCodable? = nil,
agentid: AnyCodable? = nil,
scope: ApprovalScope? = nil,
alloweddecisions: [ApprovalDecision])
{
self.kind = kind
@@ -17646,6 +17713,7 @@ public struct ExecApprovalPresentation: Codable, Sendable {
self.host = host
self.nodeid = nodeid
self.agentid = agentid
self.scope = scope
self.alloweddecisions = alloweddecisions
}
@@ -17657,6 +17725,7 @@ public struct ExecApprovalPresentation: Codable, Sendable {
case host
case nodeid = "nodeId"
case agentid = "agentId"
case scope
case alloweddecisions = "allowedDecisions"
}
}
@@ -17670,6 +17739,7 @@ public struct PluginApprovalPresentation: Codable, Sendable {
public let pluginid: AnyCodable?
public let toolname: AnyCodable?
public let agentid: AnyCodable?
public let scope: ApprovalScope?
public let alloweddecisions: [ApprovalDecision]
public init(
@@ -17681,6 +17751,7 @@ public struct PluginApprovalPresentation: Codable, Sendable {
pluginid: AnyCodable? = nil,
toolname: AnyCodable? = nil,
agentid: AnyCodable? = nil,
scope: ApprovalScope? = nil,
alloweddecisions: [ApprovalDecision])
{
self.kind = kind
@@ -17691,6 +17762,7 @@ public struct PluginApprovalPresentation: Codable, Sendable {
self.pluginid = pluginid
self.toolname = toolname
self.agentid = agentid
self.scope = scope
self.alloweddecisions = alloweddecisions
}
@@ -17703,6 +17775,7 @@ public struct PluginApprovalPresentation: Codable, Sendable {
case pluginid = "pluginId"
case toolname = "toolName"
case agentid = "agentId"
case scope
case alloweddecisions = "allowedDecisions"
}
}
@@ -18321,6 +18394,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let security: AnyCodable?
public let ask: AnyCodable?
public let warningtext: AnyCodable?
public let scope: ApprovalScope?
public let unavailabledecisions: [String]?
public let commandspans: [[String: AnyCodable]]?
public let agentid: AnyCodable?
@@ -18352,6 +18426,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
security: AnyCodable? = nil,
ask: AnyCodable? = nil,
warningtext: AnyCodable? = nil,
scope: ApprovalScope? = nil,
unavailabledecisions: [String]? = nil,
commandspans: [[String: AnyCodable]]? = nil,
agentid: AnyCodable? = nil,
@@ -18382,6 +18457,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.security = security
self.ask = ask
self.warningtext = warningtext
self.scope = scope
self.unavailabledecisions = unavailabledecisions
self.commandspans = commandspans
self.agentid = agentid
@@ -18414,6 +18490,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
case security
case ask
case warningtext = "warningText"
case scope
case unavailabledecisions = "unavailableDecisions"
case commandspans = "commandSpans"
case agentid = "agentId"
@@ -18778,6 +18855,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
public let description: String
public let detail: String?
public let severity: String?
public let scope: ApprovalScope?
public let toolname: String?
public let toolcallid: String?
public let alloweddecisions: [String]?
@@ -18797,6 +18875,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
description: String,
detail: String? = nil,
severity: String? = nil,
scope: ApprovalScope? = nil,
toolname: String? = nil,
toolcallid: String? = nil,
alloweddecisions: [String]? = nil,
@@ -18815,6 +18894,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
self.description = description
self.detail = detail
self.severity = severity
self.scope = scope
self.toolname = toolname
self.toolcallid = toolcallid
self.alloweddecisions = alloweddecisions
@@ -18835,6 +18915,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
case description
case detail
case severity
case scope
case toolname = "toolName"
case toolcallid = "toolCallId"
case alloweddecisions = "allowedDecisions"
@@ -21655,6 +21736,40 @@ public enum ToolsGitHubAuthorizePollResult: Codable, Sendable {
}
}
public enum ApprovalScope: Codable, Sendable {
case messageSend(MessageSendApprovalScope)
case payment(PaymentApprovalScope)
case externalPost(ExternalPostApprovalScope)
private enum CodingKeys: String, CodingKey {
case discriminator = "kind"
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let discriminator = try container.decode(String.self, forKey: .discriminator)
switch discriminator {
case "message-send": self = try .messageSend(MessageSendApprovalScope(from: decoder))
case "payment": self = try .payment(PaymentApprovalScope(from: decoder))
case "external-post": self = try .externalPost(ExternalPostApprovalScope(from: decoder))
default:
throw DecodingError.dataCorruptedError(
forKey: .discriminator,
in: container,
debugDescription: "Unknown ApprovalScope discriminator value"
)
}
}
public func encode(to encoder: Encoder) throws {
switch self {
case .messageSend(let value): try value.encode(to: encoder)
case .payment(let value): try value.encode(to: encoder)
case .externalPost(let value): try value.encode(to: encoder)
}
}
}
public enum ApprovalPresentation: Codable, Sendable {
case exec(ExecApprovalPresentation)
case plugin(PluginApprovalPresentation)
@@ -88,6 +88,65 @@ Write prompt text for the person who will approve the action:
- `timeoutMs` defaults to 120000 (2 minutes) and is capped at 600000 (10
minutes) regardless of the requested value.
## Declare approval scope
Set `requireApproval.scope` when your plugin knows the consequences of an
operation. Scope is typed, optional, and display-only: it helps reviewers
understand the action but never grants permission or changes the approval
decision. The plugin declaring the approval supplies these facts; channels never
infer scope from commands, titles, or message text.
For an email to three external recipients, include the destination, total
recipient count, an optional preview, and the audience:
```typescript
requireApproval: {
title: "Send customer update",
scope: {
kind: "message-send",
target: "email",
recipientCount: 3,
recipients: ["alice@example.com", "bob@example.com"],
audience: "external",
},
}
```
For a payment, provide the exact decimal amount as a string, its currency, and
the payee or payment system:
```typescript
requireApproval: {
title: "Pay invoice",
scope: {
kind: "payment",
amount: "49.99",
currency: "EUR",
target: "Stripe",
},
}
```
For an external post, identify its destination and declare its visibility:
```typescript
requireApproval: {
title: "Publish announcement",
scope: {
kind: "external-post",
target: "github",
visibility: "public",
},
}
```
Message audiences can be `internal` or `external`; external-post visibility can
be `public` or `restricted`. Recipient previews contain at most five identities.
All strings are sanitized and bounded before display: targets and recipient
identities are limited to 128 characters, payment amounts to 40, and currencies
to 12. If sanitization would exceed a bound, OpenClaw omits the scope while
preserving the normal approval prompt.
## Decision behavior
OpenClaw creates a pending approval with a `plugin:` ID, delivers it to the
+17
View File
@@ -511,6 +511,23 @@ context when forwarding approved `system.run` requests:
- Once approved, the final forwarded `system.run` call reuses the stored plan instead of trusting later caller edits.
- If the caller changes `command`, `rawCommand`, `cwd`, `agentId`, or `sessionKey` after the approval request was created, the gateway rejects the forwarded run as an approval mismatch.
## Approval scope summaries
An approval owner can attach a typed, display-only scope describing the action's
blast radius. OpenClaw renders the sanitized summary on channel approval cards
and includes the bounded scope in the safe approval presentation available to
Control UI clients. Scope never grants authorization or changes approval policy.
- `message-send`: destination, recipient count, optional recipient preview, and
whether the audience is internal or external.
- `payment`: exact decimal amount, currency, and payee or payment system.
- `external-post`: destination and whether the post is public or restricted.
For example, an email approval might show `Send to 3 recipients via email
(external): alice@example.com, bob@example.com, +1 more`. Owners supply these
facts; channels never infer them from commands or message text. Without a
declared scope, approval cards render exactly as before.
## System events and denials
Exec lifecycle posts an `Exec finished` system message to the agent's
@@ -225,6 +225,7 @@ function buildIMessageExecPendingPayload(params: { request: ExecApprovalRequest;
cwd: params.request.request.cwd ?? undefined,
host: params.request.request.host === "node" ? "node" : "gateway",
nodeId: params.request.request.nodeId ?? undefined,
scope: params.request.request.scope ?? undefined,
sessionKey: params.request.request.sessionKey ?? null,
expiresAtMs: params.request.expiresAtMs,
nowMs: params.nowMs,
@@ -308,6 +308,7 @@ function buildPendingApprovalContent(params: {
toolName: params.view.toolName ?? undefined,
pluginId: params.view.pluginId ?? undefined,
agentId: params.view.agentId ?? undefined,
scope: params.view.scope ?? undefined,
},
createdAtMs: 0,
expiresAtMs: params.view.expiresAtMs,
@@ -326,6 +327,7 @@ function buildPendingApprovalContent(params: {
cwd: params.view.cwd ?? undefined,
host: params.view.host === "node" ? "node" : "gateway",
nodeId: params.view.nodeId ?? undefined,
scope: params.view.scope ?? undefined,
sessionKey: params.view.sessionKey ?? undefined,
expiresAtMs: params.view.expiresAtMs,
nowMs: params.nowMs,
@@ -394,6 +394,10 @@ describe("slackApprovalNativeRuntime", () => {
metadata: [
{ label: "Severity", value: "Warning" },
{ label: "Plugin", value: "computer-use" },
{
label: "Scope",
value: "Send to 3 recipients via email (external): alice@example.com, +2 more",
},
],
decisions: ["allow-once", "allow-always", "deny"],
});
@@ -404,6 +408,12 @@ describe("slackApprovalNativeRuntime", () => {
);
expect(payload.text).toContain("Share screen with Computer Use");
expect(payload.text).toContain("*Approval ID:* plugin:req-1");
expect(payload.text).toContain(
"*Scope:* Send to 3 recipients via email (external): alice@example.com, +2 more",
);
expect(readMrkdwnTexts(payload.blocks)).toContain(
"*Scope:* Send to 3 recipients via email (external): alice@example.com, +2 more",
);
expect(payload.text).not.toContain("*Command*");
const actionsBlock = findSlackActionsBlock(
payload.blocks as Array<{ type?: string; elements?: unknown[] }>,
@@ -96,6 +96,51 @@ describe("telegramApprovalNativeRuntime", () => {
"tga1:e:o:req-1",
"tga1:e:d:req-1",
]);
expect(payload.text).not.toContain("Scope:");
});
it("renders owner-declared plugin approval scope in pending text", async () => {
const scope = {
kind: "message-send" as const,
target: "email",
recipientCount: 3,
recipients: ["alice@example.com"],
audience: "external" as const,
};
const payload = (await telegramApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: { token: "tg-token" },
request: {
approvalKind: "plugin",
id: "plugin:req-1",
request: {
title: "Send email",
description: "Deliver the requested announcement.",
scope,
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "plugin",
nowMs: 0,
view: {
approvalKind: "plugin",
phase: "pending",
approvalId: "plugin:req-1",
title: "Send email",
description: "Deliver the requested announcement.",
severity: "warning",
scope,
metadata: [],
actions: [],
expiresAtMs: 60_000,
},
})) as TelegramPayload;
expect(payload.text).toContain(
"Scope: Send to 3 recipients via email (external): alice@example.com, +2 more",
);
});
it("renders resolved and expired events as visible terminal receipts", async () => {
@@ -101,6 +101,7 @@ function buildPendingPayload(params: {
params.view.approvalKind === "exec" && params.view.host === "node" ? "node" : "gateway",
nodeId:
params.view.approvalKind === "exec" ? (params.view.nodeId ?? undefined) : undefined,
scope: params.view.approvalKind === "exec" ? (params.view.scope ?? undefined) : undefined,
allowedDecisions: params.view.actions.map((action) => action.decision),
expiresAtMs: params.request.expiresAtMs,
nowMs: params.nowMs,
@@ -40,6 +40,7 @@ export function buildTelegramExecApprovalPendingPayload(params: {
cwd: params.request.request.cwd ?? undefined,
host: params.request.request.host === "node" ? "node" : "gateway",
nodeId: params.request.request.nodeId ?? undefined,
scope: params.request.request.scope ?? undefined,
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(params.request.request),
expiresAtMs: params.request.expiresAtMs,
nowMs: params.nowMs,
@@ -81,6 +81,38 @@ describe("unified approval protocol validators", () => {
}
});
it("accepts bounded owner-declared approval scopes and rejects unknown scope fields", () => {
const scopes = [
{
kind: "message-send",
target: "email",
recipientCount: 3,
recipients: ["alice@example.com", "bob@example.com"],
audience: "external",
},
{ kind: "payment", amount: "49.99", currency: "EUR", target: "Stripe" },
{ kind: "external-post", target: "github", visibility: "public" },
] as const;
for (const scope of scopes) {
expect(validateApprovalPresentation({ ...execPresentation, scope })).toBe(true);
expect(validateApprovalPresentation({ ...pluginPresentation, scope })).toBe(true);
expect(
validateApprovalPresentation({ ...pluginPresentation, scope: { ...scope, extra: true } }),
).toBe(false);
}
expect(validateApprovalPresentation({ ...systemAgentPresentation, scope: scopes[0] })).toBe(
false,
);
expect(
validateApprovalPresentation({
...pluginPresentation,
scope: { ...scopes[0], recipients: Array(6).fill("person@example.com") },
}),
).toBe(false);
});
it("keeps deny available on every presentation and resolve request", () => {
expect(
validateApprovalPresentation({
@@ -601,6 +601,7 @@ export {
ApprovalAllowDecisionSchema,
ApprovalTerminalReasonSchema,
PluginApprovalSeveritySchema,
ApprovalScopeSchema,
ExecApprovalPresentationSchema,
PluginApprovalPresentationSchema,
ApprovalPresentationSchema,
@@ -72,6 +72,42 @@ export const PluginApprovalSeveritySchema = Type.Union([
Type.Literal("critical"),
]);
/** Message/email delivery blast radius declared by the approval owner. */
export const MessageSendApprovalScopeSchema = closedObject({
kind: Type.Literal("message-send"),
target: Type.String({ minLength: 1, maxLength: 128 }),
recipientCount: Type.Integer({ minimum: 1, maximum: 1_000_000 }),
recipients: Type.Optional(
Type.Array(Type.String({ minLength: 1, maxLength: 128 }), { maxItems: 5 }),
),
audience: Type.Optional(Type.Union([Type.Literal("internal"), Type.Literal("external")])),
});
/** Payment blast radius declared by the approval owner. */
export const PaymentApprovalScopeSchema = closedObject({
kind: Type.Literal("payment"),
amount: Type.String({ minLength: 1, maxLength: 40 }),
currency: Type.String({ minLength: 1, maxLength: 12 }),
target: Type.String({ minLength: 1, maxLength: 128 }),
});
/** External publication blast radius declared by the approval owner. */
export const ExternalPostApprovalScopeSchema = closedObject({
kind: Type.Literal("external-post"),
target: Type.String({ minLength: 1, maxLength: 128 }),
visibility: Type.Union([Type.Literal("public"), Type.Literal("restricted")]),
});
/**
* Owner-declared blast-radius facts for a pending approval. Variants are
* named schemas so native protocol generators emit the discriminated union.
*/
export const ApprovalScopeSchema = Type.Union([
MessageSendApprovalScopeSchema,
PaymentApprovalScopeSchema,
ExternalPostApprovalScopeSchema,
]);
const ApprovalAllowedDecisionsSchema = Type.Array(ApprovalDecisionSchema, {
minItems: 1,
maxItems: 3,
@@ -96,6 +132,7 @@ export const ExecApprovalPresentationSchema = Type.Object(
host: Type.Optional(Type.Union([Type.String(), Type.Null()])),
nodeId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
scope: Type.Optional(ApprovalScopeSchema),
allowedDecisions: ApprovalAllowedDecisionsSchema,
},
{
@@ -115,6 +152,7 @@ export const PluginApprovalPresentationSchema = closedObject({
pluginId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
toolName: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
scope: Type.Optional(ApprovalScopeSchema),
allowedDecisions: ApprovalAllowedDecisionsSchema,
});
@@ -312,6 +350,7 @@ export type ApprovalDecision = Static<typeof ApprovalDecisionSchema>;
export type ApprovalAllowDecision = Static<typeof ApprovalAllowDecisionSchema>;
export type ApprovalTerminalReason = Static<typeof ApprovalTerminalReasonSchema>;
export type PluginApprovalSeverity = Static<typeof PluginApprovalSeveritySchema>;
export type ApprovalScope = Static<typeof ApprovalScopeSchema>;
export type ExecApprovalPresentation = Static<typeof ExecApprovalPresentationSchema>;
export type PluginApprovalPresentation = Static<typeof PluginApprovalPresentationSchema>;
export type SystemAgentApprovalPresentation = Static<typeof SystemAgentApprovalPresentationSchema>;
@@ -1,7 +1,7 @@
import type { Static } from "typebox";
// Gateway Protocol schema module defines protocol validation shapes.
import { Type } from "typebox";
import { ApprovalChannelReviewerSchema } from "./approvals.js";
import { ApprovalChannelReviewerSchema, ApprovalScopeSchema } from "./approvals.js";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
@@ -266,6 +266,7 @@ export const ExecApprovalRequestParamsSchema = closedObject({
security: Type.Optional(Type.Union([Type.String(), Type.Null()])),
ask: Type.Optional(Type.Union([Type.String(), Type.Null()])),
warningText: Type.Optional(Type.Union([Type.String(), Type.Null()])),
scope: Type.Optional(ApprovalScopeSchema),
unavailableDecisions: Type.Optional(
Type.Array(Type.String({ enum: ["allow-always"] }), {
minItems: 1,
@@ -1,7 +1,7 @@
import type { Static } from "typebox";
// Gateway Protocol schema module defines protocol validation shapes.
import { Type } from "typebox";
import { ApprovalChannelReviewerSchema } from "./approvals.js";
import { ApprovalChannelReviewerSchema, ApprovalScopeSchema } from "./approvals.js";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
@@ -29,6 +29,7 @@ export const PluginApprovalRequestParamsSchema = closedObject({
}),
),
severity: Type.Optional(Type.String({ enum: ["info", "warning", "critical"] })),
scope: Type.Optional(ApprovalScopeSchema),
toolName: Type.Optional(Type.String()),
toolCallId: Type.Optional(Type.String()),
allowedDecisions: Type.Optional(
@@ -11,6 +11,10 @@ export const ApprovalProtocolSchemas = {
ApprovalExpiredReason: approvals.ApprovalExpiredReasonSchema,
ApprovalCancelledReason: approvals.ApprovalCancelledReasonSchema,
PluginApprovalSeverity: approvals.PluginApprovalSeveritySchema,
MessageSendApprovalScope: approvals.MessageSendApprovalScopeSchema,
PaymentApprovalScope: approvals.PaymentApprovalScopeSchema,
ExternalPostApprovalScope: approvals.ExternalPostApprovalScopeSchema,
ApprovalScope: approvals.ApprovalScopeSchema,
ExecApprovalPresentation: approvals.ExecApprovalPresentationSchema,
PluginApprovalPresentation: approvals.PluginApprovalPresentationSchema,
SystemAgentApprovalPresentation: approvals.SystemAgentApprovalPresentationSchema,
+7 -2
View File
@@ -313,7 +313,10 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// +2: restore shipped channel setup helpers until stable packages migrate.
// +1: canonical untrusted audio-transcript formatter for channel plugins.
// +2: embedded foreground prompt context builder and its public context type.
4342,
// +1: typed owner-declared approval-scope contract for plugin-authored approvals.
// -5: approval display sanitizers moved to a non-public leaf module
// (exec-approval-text-sanitize) to break the exec-approvals cycle.
4338,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@@ -406,7 +409,9 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// +2: restore shipped channel setup helpers until stable packages migrate.
// +1: canonical untrusted audio-transcript formatter for channel plugins.
// +1: embedded foreground prompt context builder.
2582,
// -4: approval display sanitizers moved to a non-public leaf module
// (exec-approval-text-sanitize) to break the exec-approvals cycle.
2578,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
@@ -5,6 +5,7 @@
*/
import { addTimerTimeoutGraceMs } from "@openclaw/normalization-core/number-coercion";
import { GatewayClientRequestError } from "../gateway/client.js";
import { sanitizeApprovalScope } from "../infra/approval-scope.js";
import { isEmbeddedMode } from "../infra/embedded-mode.js";
import { getEmbeddedPluginApprovalBroker } from "../infra/embedded-plugin-approval-broker.js";
import { formatErrorMessage } from "../infra/errors.js";
@@ -201,6 +202,7 @@ async function requestPluginToolApproval(params: {
pluginId: approval.pluginId,
title: approval.title,
description: approval.description,
...(approval.scope ? { scope: sanitizeApprovalScope(approval.scope) } : {}),
severity: approval.severity,
allowedDecisions: approval.allowedDecisions,
toolName: params.toolName,
@@ -288,6 +290,7 @@ async function requestPluginToolApproval(params: {
{
title: approval.title,
description: approval.description,
...(approval.scope ? { scope: approval.scope } : {}),
severity: approval.severity,
allowedDecisions: approval.allowedDecisions,
toolName: params.toolName,
@@ -235,6 +235,7 @@ describe("runBeforeToolCallHook — embedded mode approvals", () => {
pluginId: "test-plugin",
title: "Needs approval",
description: "Test approval request",
scope: { kind: "external-post", target: "github", visibility: "public" },
severity: "info",
timeoutBehavior: "allow",
onResolution,
@@ -251,6 +252,11 @@ describe("runBeforeToolCallHook — embedded mode approvals", () => {
await vi.waitFor(() => {
expect(broker.listPending()).toHaveLength(1);
});
expect(broker.listPending()[0]?.request.scope).toEqual({
kind: "external-post",
target: "git\\u{202E}hub",
visibility: "public",
});
broker.stop(new Error("local TUI stopped"));
@@ -1,5 +1,5 @@
import { addTimerTimeoutGraceMs } from "@openclaw/normalization-core/number-coercion";
import { sanitizeExecApprovalWarningTextWithStatus } from "../../infra/exec-approval-command-display.js";
import { sanitizeExecApprovalWarningTextWithStatus } from "../../infra/exec-approval-text-sanitize.js";
import type { ExecAsk, ExecSecurity } from "../../infra/exec-approvals.js";
import {
DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS,
+1 -1
View File
@@ -6,7 +6,7 @@ import { getTerminalTableWidth, renderTable } from "../../packages/terminal-core
import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
import { readConfigFileSnapshot, replaceConfigFile } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { sanitizeExecApprovalDisplayText } from "../infra/exec-approval-command-display.js";
import { sanitizeExecApprovalDisplayText } from "../infra/exec-approval-text-sanitize.js";
import {
collectExecPolicyScopeSnapshots,
SESSION_EXEC_OVERRIDES_NOTE,
+3 -1
View File
@@ -4,10 +4,11 @@ import { randomUUID } from "node:crypto";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { recordRuntimeActionDecision } from "../audit/runtime-action-decision.js";
import { sanitizeApprovalScope } from "../infra/approval-scope.js";
import {
sanitizeExecApprovalDisplayText,
sanitizeExecApprovalWarningText,
} from "../infra/exec-approval-command-display.js";
} from "../infra/exec-approval-text-sanitize.js";
import { resolveCanonicalPluginApprovalRequestAllowedDecisions } from "../infra/plugin-approval-canonical-decisions.js";
import type { PluginApprovalRequestPayload } from "../infra/plugin-approvals.js";
import { resolvePluginApprovalTimeoutMs } from "../infra/plugin-approvals.js";
@@ -145,6 +146,7 @@ function createApprovalRuntime(params: {
sanitizeExecApprovalWarningText(normalizeOptionalString(input.description) ?? ""),
256,
),
scope: input.scope ? sanitizeApprovalScope(input.scope) : null,
severity: input.severity ?? "warning",
...(input.allowedDecisions === undefined
? {}
+6 -3
View File
@@ -9,15 +9,16 @@ import {
validateExecApprovalResolveParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { resolveExecCommandHighlighting } from "../../config/exec-command-highlighting.js";
import { sanitizeApprovalScope, type ApprovalScope } from "../../infra/approval-scope.js";
import { resolveCommandAnalysisSummaryForDisplay } from "../../infra/command-analysis/explain.js";
import { lookupCronRunExecSource } from "../../infra/cron-run-exec-source.js";
import { resolveExecApprovalCommandDisplay } from "../../infra/exec-approval-command-display.js";
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
import {
resolveExecApprovalCommandDisplay,
sanitizeExecApprovalDisplayText,
sanitizeExecApprovalDisplayTextWithStatus,
sanitizeExecApprovalWarningText,
} from "../../infra/exec-approval-command-display.js";
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
} from "../../infra/exec-approval-text-sanitize.js";
import { normalizeExecAsk, normalizeExecSecurity } from "../../infra/exec-approvals-core.js";
import {
DEFAULT_EXEC_APPROVAL_TIMEOUT_MS,
@@ -170,6 +171,7 @@ export function createExecApprovalHandlers(
security?: string;
ask?: string;
warningText?: string | null;
scope?: ApprovalScope;
unavailableDecisions?: string[];
commandSpans?: {
startIndex: number;
@@ -367,6 +369,7 @@ export function createExecApprovalHandlers(
security: normalizeExecSecurity(p.security) ?? null,
ask: normalizeExecAsk(p.ask) ?? null,
warningText: warningText ? sanitizeExecApprovalWarningText(warningText) : null,
scope: p.scope ? sanitizeApprovalScope(p.scope) : null,
commandAnalysis,
commandSpans,
unavailableDecisions: unavailableDecisions.length > 0 ? unavailableDecisions : undefined,
@@ -0,0 +1,88 @@
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, vi } from "vitest";
import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js";
import { ExecApprovalManager } from "../exec-approval-manager.js";
import { createPluginApprovalHandlers } from "./plugin-approval.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
function createApprovalScopeRequest(scope: unknown) {
const manager = new ExecApprovalManager<PluginApprovalRequestPayload>({ approvalKind: "plugin" });
const respond = vi.fn();
const params = {
title: "Sensitive action",
description: "Review the action",
scope,
twoPhase: true,
};
const options = {
req: { method: "plugin.approval.request", params, id: "request" },
params,
respond,
client: { connId: "reviewer", connect: { client: { id: "reviewer" } } },
context: {
broadcast: vi.fn(),
logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
hasExecApprovalClients: () => true,
},
} as unknown as GatewayRequestHandlerOptions;
const handler = expectDefined(
createPluginApprovalHandlers(manager)["plugin.approval.request"],
"plugin approval request handler",
);
return { manager, respond, handler, options };
}
describe("plugin approval request scopes", () => {
it("sanitizes owner-declared scope before storing or broadcasting the approval", async () => {
const { manager, handler, options } = createApprovalScopeRequest({
kind: "message-send",
target: "email\u202Esystem",
recipientCount: 3,
recipients: ["alice\u200B@example.com", "bob@example.com"],
audience: "external",
});
const pending = handler(options);
await vi.waitFor(() => expect(manager.listPendingRecords()).toHaveLength(1));
const record = expectDefined(manager.listPendingRecords()[0], "pending plugin approval");
expect(record.request.scope).toEqual({
kind: "message-send",
target: "email\\u{202E}system",
recipientCount: 3,
recipients: ["alice\\u{200B}@example.com", "bob@example.com"],
audience: "external",
});
manager.resolve(record.id, "allow-once");
await pending;
});
it("drops scope after escaped text exceeds its bounds without rejecting approval", async () => {
const { manager, handler, options } = createApprovalScopeRequest({
kind: "external-post",
target: `github${"\u202E".repeat(20)}`,
visibility: "public",
});
const pending = handler(options);
await vi.waitFor(() => expect(manager.listPendingRecords()).toHaveLength(1));
const record = expectDefined(manager.listPendingRecords()[0], "pending plugin approval");
expect(record.request.scope).toBeNull();
manager.resolve(record.id, "allow-once");
await pending;
});
it.each([
{ kind: "untyped", target: "email" },
{ kind: "external-post", target: "github", visibility: "public", extra: true },
])("rejects malformed or non-closed owner-declared scope", async (scope) => {
const { manager, respond, handler, options } = createApprovalScopeRequest(scope);
await handler(options);
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: expect.any(String) }),
);
expect(manager.listPendingRecords()).toHaveLength(0);
});
});
@@ -7,11 +7,12 @@ import {
validatePluginApprovalRequestParams,
validatePluginApprovalResolveParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { sanitizeApprovalScope, type ApprovalScope } from "../../infra/approval-scope.js";
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
import {
sanitizeExecApprovalDisplayText,
sanitizeExecApprovalWarningText,
} from "../../infra/exec-approval-command-display.js";
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
} from "../../infra/exec-approval-text-sanitize.js";
import { resolveCanonicalPluginApprovalRequestAllowedDecisions } from "../../infra/plugin-approval-canonical-decisions.js";
import type {
PluginApprovalRequest,
@@ -88,6 +89,7 @@ export function createPluginApprovalHandlers(
description: string;
detail?: string | null;
severity?: string | null;
scope?: ApprovalScope;
toolName?: string | null;
toolCallId?: string | null;
allowedDecisions?: string[] | null;
@@ -188,6 +190,7 @@ export function createPluginApprovalHandlers(
pluginId: trustedAgentRuntime?.approvalOwnerPluginId ?? sanitizeMeta(p.pluginId),
title: sanitizedTitle,
description: sanitizedDescription,
scope: p.scope ? sanitizeApprovalScope(p.scope) : null,
detail:
rawDetail === null
? null
+34
View File
@@ -26,6 +26,40 @@ function buildPluginPresentation(request: {
}
describe("buildApprovalPresentation", () => {
it.each([
{ kind: "exec", request: { command: "printf safe" } },
{
kind: "plugin",
request: { title: "Review payment", description: "The plugin needs operator consent." },
},
] as const)("sanitizes $kind scope and drops scope that exceeds its wire bound", (params) => {
const scope = {
kind: "payment",
amount: "49.99",
currency: "EUR",
target: "Stripe\u202E",
} as const;
const presentation = buildApprovalPresentation({
...params,
request: { ...params.request, scope },
allowedDecisions,
});
expect(presentation).toMatchObject({
kind: params.kind,
scope: { ...scope, target: "Stripe\\u{202E}" },
});
const oversizedScopePresentation = buildApprovalPresentation({
...params,
request: { ...params.request, scope: { ...scope, target: `${"x".repeat(125)}\u202E` } },
allowedDecisions,
});
expect(oversizedScopePresentation).toMatchObject({ kind: params.kind });
expect(oversizedScopePresentation).not.toHaveProperty("scope");
});
it("sanitizes exec routing metadata and preserves empty values as null", () => {
const githubToken = `ghp_${"a".repeat(100)}`;
const presentation = buildExecPresentation({
+7 -2
View File
@@ -7,11 +7,12 @@ import type {
ApprovalKind,
ApprovalPresentation,
} from "../../packages/gateway-protocol/src/index.js";
import { sanitizeApprovalScope } from "./approval-scope.js";
import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-display.js";
import {
resolveExecApprovalCommandDisplay,
sanitizeExecApprovalDisplayText,
sanitizeExecApprovalWarningText,
} from "./exec-approval-command-display.js";
} from "./exec-approval-text-sanitize.js";
import type { ExecApprovalRequestPayload } from "./exec-approvals.js";
import {
PLUGIN_APPROVAL_DESCRIPTION_MAX_LENGTH,
@@ -59,6 +60,7 @@ function buildExecApprovalPresentation(params: {
typeof request.warningText === "string" && request.warningText.trim()
? sanitizeExecApprovalWarningText(request.warningText)
: null;
const scope = request.scope ? sanitizeApprovalScope(request.scope) : null;
return {
kind: "exec",
commandText,
@@ -67,6 +69,7 @@ function buildExecApprovalPresentation(params: {
host: sanitizeOptionalSingleLine(request.host),
nodeId: sanitizeOptionalSingleLine(request.nodeId),
agentId: sanitizeOptionalSingleLine(request.agentId),
...(scope ? { scope } : {}),
allowedDecisions: normalizeDecisionList(params.allowedDecisions),
};
}
@@ -102,6 +105,7 @@ function buildPluginApprovalPresentation(params: {
const detail = rawDetail
? truncatePluginApprovalDetail(sanitizeExecApprovalWarningText(rawDetail))
: null;
const scope = request.scope ? sanitizeApprovalScope(request.scope) : null;
return {
kind: "plugin",
title,
@@ -111,6 +115,7 @@ function buildPluginApprovalPresentation(params: {
pluginId: sanitizeOptionalSingleLine(request.pluginId),
toolName: sanitizeOptionalSingleLine(request.toolName),
agentId: sanitizeOptionalSingleLine(request.agentId),
...(scope ? { scope } : {}),
allowedDecisions: normalizeDecisionList(params.allowedDecisions),
};
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
sanitizeApprovalScope,
summarizeApprovalScope,
type ApprovalScope,
} from "./approval-scope.js";
describe("approval scope", () => {
it.each([
[
{
kind: "message-send",
target: "email",
recipientCount: 3,
recipients: ["alice@x.com", "bob@y.com"],
audience: "external",
},
"Send to 3 recipients via email (external): alice@x.com, bob@y.com, +1 more",
],
[
{ kind: "message-send", target: "slack #general", recipientCount: 1 },
"Send to 1 recipient via slack #general",
],
[
{ kind: "payment", amount: "49.99", currency: "EUR", target: "Stripe" },
"Pay 49.99 EUR to Stripe",
],
[{ kind: "external-post", target: "github", visibility: "public" }, "Post publicly to github"],
[
{ kind: "external-post", target: "github", visibility: "restricted" },
"Post restricted to github",
],
] satisfies [ApprovalScope, string][])("summarizes $kind", (scope, expected) => {
expect(summarizeApprovalScope(scope)).toBe(expected);
});
it("preserves clean scope fields and visibly escapes spoofing characters", () => {
const cleanScope = {
kind: "message-send",
target: "email",
recipientCount: 1,
recipients: ["alice@example.com"],
audience: "internal",
} satisfies ApprovalScope;
expect(sanitizeApprovalScope(cleanScope)).toEqual(cleanScope);
expect(sanitizeApprovalScope({ ...cleanScope, target: "mail\u202Ebox" })).toMatchObject({
target: "mail\\u{202E}box",
});
expect(sanitizeApprovalScope({ ...cleanScope, target: "🦞".repeat(128) })).toMatchObject({
target: "🦞".repeat(128),
});
});
it("clamps recipient previews to the declared recipient count", () => {
const clamped = sanitizeApprovalScope({
kind: "message-send",
target: "email",
recipientCount: 1,
recipients: ["alice@example.com", "bob@example.com"],
});
expect(clamped).toMatchObject({ recipientCount: 1, recipients: ["alice@example.com"] });
expect(
summarizeApprovalScope({
kind: "message-send",
target: "email",
recipientCount: 1,
recipients: (clamped as Extract<ApprovalScope, { kind: "message-send" }>).recipients,
}),
).toBe("Send to 1 recipient via email: alice@example.com");
});
it.each([
{ kind: "message-send", target: `${"x".repeat(127)}\u202E`, recipientCount: 1 },
{
kind: "message-send",
target: "email",
recipientCount: 1,
recipients: [`${"x".repeat(127)}\u202E`],
},
{ kind: "payment", amount: `${"x".repeat(39)}\u202E`, currency: "EUR", target: "Stripe" },
{ kind: "payment", amount: "49.99", currency: `${"x".repeat(11)}\u202E`, target: "Stripe" },
{ kind: "external-post", target: `${"x".repeat(127)}\u202E`, visibility: "public" },
] satisfies ApprovalScope[])(
"drops scopes that exceed a bound after escaping ($kind)",
(scope) => {
expect(sanitizeApprovalScope(scope)).toBeNull();
},
);
});
+63
View File
@@ -0,0 +1,63 @@
import type { Static } from "typebox";
import type { ApprovalScopeSchema } from "../../packages/gateway-protocol/src/schema/approvals.js";
import { sanitizeExecApprovalDisplayText } from "./exec-approval-text-sanitize.js";
export type ApprovalScope = Static<typeof ApprovalScopeSchema>;
function exceedsApprovalScopeStringLimit(value: string, maxLength: number): boolean {
return Array.from(value).length > maxLength;
}
export function summarizeApprovalScope(scope: ApprovalScope): string {
switch (scope.kind) {
case "message-send": {
const recipientLabel = scope.recipientCount === 1 ? "recipient" : "recipients";
const audience = scope.audience ? ` (${scope.audience})` : "";
const recipients = scope.recipients ?? [];
const remaining = scope.recipientCount - recipients.length;
const preview = recipients.length
? `: ${[...recipients, ...(remaining > 0 ? [`+${remaining} more`] : [])].join(", ")}`
: "";
return `Send to ${scope.recipientCount} ${recipientLabel} via ${scope.target}${audience}${preview}`;
}
case "payment":
return `Pay ${scope.amount} ${scope.currency} to ${scope.target}`;
case "external-post":
return `Post ${scope.visibility === "public" ? "publicly" : "restricted"} to ${scope.target}`;
}
scope satisfies never;
throw new Error("Unsupported approval scope");
}
export function sanitizeApprovalScope(scope: ApprovalScope): ApprovalScope | null {
const target = sanitizeExecApprovalDisplayText(scope.target);
if (exceedsApprovalScopeStringLimit(target, 128)) {
return null;
}
switch (scope.kind) {
case "message-send": {
// Previews are a subset of recipientCount; the count stays authoritative,
// so excess previews are clamped rather than rendered inconsistently.
const recipients = scope.recipients
?.slice(0, scope.recipientCount)
.map(sanitizeExecApprovalDisplayText);
if (recipients?.some((recipient) => exceedsApprovalScopeStringLimit(recipient, 128))) {
return null;
}
return { ...scope, target, ...(recipients ? { recipients } : {}) };
}
case "payment": {
const amount = sanitizeExecApprovalDisplayText(scope.amount);
const currency = sanitizeExecApprovalDisplayText(scope.currency);
return exceedsApprovalScopeStringLimit(amount, 40) ||
exceedsApprovalScopeStringLimit(currency, 12)
? null
: { ...scope, amount, currency, target };
}
case "external-post":
return { ...scope, target };
}
scope satisfies never;
return null;
}
+34
View File
@@ -21,6 +21,13 @@ describe("buildPendingApprovalView", () => {
riskKinds: ["inline-eval"],
warningLines: ["Contains inline-eval: python -c"],
},
scope: {
kind: "message-send",
target: "email",
recipientCount: 3,
recipients: ["alice@example.com", "bob@example.com"],
audience: "external",
},
},
};
@@ -31,6 +38,12 @@ describe("buildPendingApprovalView", () => {
throw new Error("expected exec approval view");
}
expect(view.commandAnalysis?.warningLines).toEqual(["Contains inline-eval: python -c"]);
expect(view.scope).toEqual(request.request.scope);
expect(view.metadata).toContainEqual({
label: "Scope",
value:
"Send to 3 recipients via email (external): alice@example.com, bob@example.com, +1 more",
});
expect(view.actions[0]?.action).toEqual({
type: "approval",
approvalId: "approval-id",
@@ -47,12 +60,15 @@ describe("buildPendingApprovalView", () => {
request: {
title: "Use protected tool",
description: "The plugin needs operator consent.",
scope: { kind: "external-post", target: "github", visibility: "public" },
},
};
expect(resolveApprovalRequestKind(request)).toBe("plugin");
const view = buildPendingApprovalView(request);
expect(view.approvalKind).toBe("plugin");
expect(view.scope).toEqual(request.request.scope);
expect(view.metadata).toContainEqual({ label: "Scope", value: "Post publicly to github" });
expect(view.actions[0]?.action).toEqual({
type: "approval",
approvalId: "custom-id-without-prefix",
@@ -61,6 +77,24 @@ describe("buildPendingApprovalView", () => {
});
});
const approvalRequestBase = { id: "approval-id", createdAtMs: 1, expiresAtMs: 2 };
it.each([
{ request: { ...approvalRequestBase, request: { command: "echo safe" } }, metadata: [] },
{
request: {
...approvalRequestBase,
request: { title: "Use protected tool", description: "The plugin needs consent." },
},
metadata: [{ label: "Severity", value: "Warning" }],
},
])("preserves existing metadata when no approval scope is declared", ({ request, metadata }) => {
const view = buildPendingApprovalView(request);
expect(view.metadata).toEqual(metadata);
expect(view).not.toHaveProperty("scope");
});
it("does not trust conflicting approval kind metadata", () => {
const request: PluginApprovalRequest = {
id: "plugin-approval",
+9
View File
@@ -1,4 +1,5 @@
// Builds approval prompt view models from request and resolution events.
import { summarizeApprovalScope } from "./approval-scope.js";
import { normalizeApprovalRequest } from "./approval-types.js";
import type {
ApprovalMetadataView,
@@ -35,6 +36,9 @@ function buildExecMetadata(request: ExecApprovalRequest): ApprovalMetadataView[]
if (Array.isArray(request.request.envKeys) && request.request.envKeys.length > 0) {
metadata.push({ label: "Env Overrides", value: request.request.envKeys.join(", ") });
}
if (request.request.scope) {
metadata.push({ label: "Scope", value: summarizeApprovalScope(request.request.scope) });
}
return metadata;
}
@@ -54,6 +58,9 @@ function buildPluginMetadata(request: PluginApprovalRequest): ApprovalMetadataVi
if (request.request.agentId) {
metadata.push({ label: "Agent", value: request.request.agentId });
}
if (request.request.scope) {
metadata.push({ label: "Scope", value: summarizeApprovalScope(request.request.scope) });
}
return metadata;
}
@@ -79,6 +86,7 @@ function buildExecViewBase<TPhase extends ApprovalPhase>(
envKeys: request.request.envKeys ?? undefined,
host: request.request.host ?? null,
nodeId: request.request.nodeId ?? null,
...(request.request.scope ? { scope: request.request.scope } : {}),
sessionKey: request.request.sessionKey ?? null,
};
}
@@ -96,6 +104,7 @@ function buildPluginViewBase<TPhase extends ApprovalPhase>(
metadata: buildPluginMetadata(request),
agentId: request.request.agentId ?? null,
pluginId: request.request.pluginId ?? null,
...(request.request.scope ? { scope: request.request.scope } : {}),
toolName: request.request.toolName ?? null,
severity: request.request.severity ?? "warning",
};
+3
View File
@@ -3,6 +3,7 @@ import type {
MessagePresentationAction,
MessagePresentationButton,
} from "../interactive/payload.js";
import type { ApprovalScope } from "./approval-scope.js";
import type { ApprovalRequestInput, ChannelApprovalKind } from "./approval-types.js";
import type { CommandExplanationSummary } from "./command-analysis/explain.js";
import type { ExecApprovalDecision, ExecApprovalResolved } from "./exec-approvals.js";
@@ -49,6 +50,7 @@ export type ExecApprovalViewBase = ApprovalViewBase & {
envKeys?: readonly string[];
host?: string | null;
nodeId?: string | null;
scope?: ApprovalScope | null;
sessionKey?: string | null;
};
@@ -76,6 +78,7 @@ export type PluginApprovalViewBase = ApprovalViewBase & {
approvalKind: "plugin";
agentId?: string | null;
pluginId?: string | null;
scope?: ApprovalScope | null;
toolName?: string | null;
severity: "info" | "warning" | "critical";
};
@@ -1,10 +1,10 @@
// Verifies shell command display strings for exec approval prompts.
import { describe, expect, it } from "vitest";
import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-display.js";
import {
resolveExecApprovalCommandDisplay,
sanitizeExecApprovalDisplayText,
sanitizeExecApprovalWarningText,
} from "./exec-approval-command-display.js";
} from "./exec-approval-text-sanitize.js";
function hasLoneSurrogate(value: string): boolean {
return Array.from(value).some((char) => {
+2 -200
View File
@@ -1,205 +1,7 @@
import { expectDefined } from "@openclaw/normalization-core";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
// Sanitizes command text before it is displayed in approval prompts.
import {
computeSensitiveRedactionBitmap,
redactSensitiveText,
resolveRedactOptions,
} from "../logging/redact.js";
// Resolves sanitized command/preview text for exec approval prompts.
import { sanitizeExecApprovalDisplayText } from "./exec-approval-text-sanitize.js";
import type { ExecApprovalRequestPayload } from "./exec-approvals.js";
// Escape control characters, Unicode format/line/paragraph separators, unpaired surrogates,
// and non-ASCII space separators that can spoof or break approval prompts in common UIs.
// With the Unicode regex flag, valid astral characters are full code points and do not match
// Cs; only malformed surrogate code units are escaped. Ordinary ASCII space stays unchanged.
const EXEC_APPROVAL_INVISIBLE_CHAR_REGEX =
/[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0]/gu;
const EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE =
/^[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0]$/u;
// Hard cap on input the sanitizer will process at all. Above this size we return a constant
// marker without running any regex work, so an attacker cannot force unbounded CPU/memory.
const EXEC_APPROVAL_MAX_INPUT = 256 * 1024;
// Soft cap on displayed output. Truncation happens AFTER redaction so a secret near the
// cutoff is not partially exposed when the cut lands mid-token below a pattern's minimum
// length (e.g. `ghp_` needs 20+ trailing chars before the `\b` match).
const EXEC_APPROVAL_MAX_OUTPUT = 16 * 1024;
const EXEC_APPROVAL_TRUNCATION_MARKER = "…[truncated]";
const EXEC_APPROVAL_OVERSIZED_MARKER =
"[exec approval command exceeds display size limit; full text suppressed]";
const EXEC_APPROVAL_WARNING_OVERSIZED_MARKER =
"[exec approval warning exceeds display size limit; full text suppressed]";
const BYPASS_MASK = "***";
function formatCodePointEscape(char: string): string {
return `\\u{${char.codePointAt(0)?.toString(16).toUpperCase() ?? "FFFD"}}`;
}
function normalizeDisplayLineBreaks(text: string): string {
return text.replace(/\r\n?/g, "\n").replace(/[\u2028\u2029]/g, "\n");
}
function escapeInvisibles(text: string, options?: { preserveLineBreaks?: boolean }): string {
return text.replace(EXEC_APPROVAL_INVISIBLE_CHAR_REGEX, (char) =>
options?.preserveLineBreaks && char === "\n" ? "\n" : formatCodePointEscape(char),
);
}
/** Sanitized approval text plus size-cap status for callers that need UI affordances. */
export type SanitizedExecApprovalDisplayText = {
/** Redacted, spoof-resistant command or warning text safe for an approval prompt. */
text: string;
/** True when sanitized output exceeded the display cap and was shortened. */
truncated: boolean;
/** True when raw input exceeded the hard cap and was replaced with a fixed marker. */
oversized: boolean;
};
function truncateForDisplay(text: string): SanitizedExecApprovalDisplayText {
if (text.length <= EXEC_APPROVAL_MAX_OUTPUT) {
return { text, truncated: false, oversized: false };
}
return {
text: truncateUtf16Safe(text, EXEC_APPROVAL_MAX_OUTPUT) + EXEC_APPROVAL_TRUNCATION_MARKER,
truncated: true,
oversized: false,
};
}
// Iterate by full Unicode code point so astral-plane invisibles (e.g. U+E0061 TAG LATIN
// SMALL LETTER A, category Cf) are matched as single characters instead of being seen as a
// surrogate pair whose halves are category Cs and would escape the invisible-char regex.
function buildStrippedView(original: string): { stripped: string; strippedToOrig: number[] } {
const strippedChars: string[] = [];
const strippedToOrig: number[] = [];
let offset = 0;
for (const cp of original) {
if (!EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE.test(cp)) {
strippedChars.push(cp);
for (let k = 0; k < cp.length; k++) {
strippedToOrig.push(offset + k);
}
}
offset += cp.length;
}
return { stripped: strippedChars.join(""), strippedToOrig };
}
function sanitizeExecApprovalDisplayTextInternal(
commandText: string,
options?: { preserveLineBreaks?: boolean; oversizedMarker?: string },
): SanitizedExecApprovalDisplayText {
if (commandText.length > EXEC_APPROVAL_MAX_INPUT) {
// Refuse to display inputs above the hard cap; anything larger must be approved through
// another channel. Running redaction on a multi-megabyte payload would be a DoS vector.
return {
text: options?.oversizedMarker ?? EXEC_APPROVAL_OVERSIZED_MARKER,
truncated: false,
oversized: true,
};
}
const rawRedacted = redactSensitiveText(commandText, { mode: "tools" });
const { stripped, strippedToOrig } = buildStrippedView(commandText);
const strippedRedacted = redactSensitiveText(stripped, { mode: "tools" });
// Fast path: stripping invisibles did not expose any additional secret-like content, so the
// raw-view redaction is sufficient. Preserve structure and show invisible-character spoof
// attempts as `\u{...}` escapes.
if (strippedRedacted === stripped) {
return truncateForDisplay(escapeInvisibles(rawRedacted, options));
}
// Detect bypass by position-bitmap coverage. Run the redaction matchers on both views and
// map stripped-view match positions back to original coordinates. If every position the
// stripped view would mask is also masked by the raw view, the raw view already covered
// everything — for example, an ordinary multi-line PEM private key where raw produces
// `BEGIN/…redacted…/END` while stripped collapses to `***`. A real bypass exists only when
// the stripped view masks at least one original position raw missed (e.g. the tail of an
// `sk-` token whose prefix-boundary was broken by a spliced zero-width or NBSP character).
const redaction = resolveRedactOptions({ mode: "tools" });
const rawMask = computeSensitiveRedactionBitmap(commandText, redaction);
const strippedMask = computeSensitiveRedactionBitmap(stripped, redaction);
let bypassDetected = false;
for (let i = 0; i < strippedMask.length; i++) {
if (
strippedMask[i] &&
!rawMask[expectDefined(strippedToOrig[i], "stripped to orig entry at i")]
) {
bypassDetected = true;
break;
}
}
if (!bypassDetected) {
return truncateForDisplay(escapeInvisibles(rawRedacted, options));
}
// Bypass path. Project the stripped-view mask back onto original positions, union with the
// raw-view mask, and emit a rendering where each contiguous masked run becomes a single
// `***` marker. Invisible characters that fall outside masked runs still render as visible
// `\u{...}` escapes so multi-line structure and spliced invisibles stay readable. The
// render loop advances by full code point so astral-plane invisibles are escaped as one
// `\u{...}` token rather than two separate surrogate escapes (or, worse, passed through
// unescaped because neither surrogate half matches the Cf regex).
const unionMask = rawMask.slice();
for (let i = 0; i < strippedMask.length; i++) {
if (strippedMask[i]) {
unionMask[expectDefined(strippedToOrig[i], "stripped to orig entry at i")] = true;
}
}
let out = "";
let i = 0;
while (i < commandText.length) {
if (unionMask[i]) {
let j = i;
while (j < commandText.length && unionMask[j]) {
j++;
}
out += BYPASS_MASK;
i = j;
continue;
}
const codePoint = commandText.codePointAt(i) ?? 0xfffd;
const cp = String.fromCodePoint(codePoint);
out +=
options?.preserveLineBreaks && cp === "\n"
? cp
: EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE.test(cp)
? formatCodePointEscape(cp)
: cp;
i += cp.length;
}
return truncateForDisplay(out);
}
/** Sanitizes exec command text for approval UI without exposing status metadata. */
export function sanitizeExecApprovalDisplayText(commandText: string): string {
return sanitizeExecApprovalDisplayTextInternal(commandText).text;
}
/**
* Sanitizes exec command text for approval UI and reports whether size caps changed it.
*/
export function sanitizeExecApprovalDisplayTextWithStatus(
commandText: string,
): SanitizedExecApprovalDisplayText {
return sanitizeExecApprovalDisplayTextInternal(commandText);
}
/**
* Sanitizes warning prose for approval UI while preserving real line boundaries.
*/
export function sanitizeExecApprovalWarningText(warningText: string): string {
return sanitizeExecApprovalWarningTextWithStatus(warningText).text;
}
/** Sanitizes warning prose and reports whether display bounds suppressed any content. */
export function sanitizeExecApprovalWarningTextWithStatus(
warningText: string,
): SanitizedExecApprovalDisplayText {
return sanitizeExecApprovalDisplayTextInternal(normalizeDisplayLineBreaks(warningText), {
preserveLineBreaks: true,
oversizedMarker: EXEC_APPROVAL_WARNING_OVERSIZED_MARKER,
});
}
function normalizePreview(commandText: string, commandPreview?: string | null): string | null {
const previewRaw = commandPreview?.trim() ?? "";
if (!previewRaw) {
+2 -4
View File
@@ -26,11 +26,9 @@ import { createPendingApprovalRegistry } from "../shared/pending-approval-regist
import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
import { matchesApprovalRequestFilters } from "./approval-request-filters.js";
import type { ChannelApprovalKind } from "./approval-types.js";
import {
resolveExecApprovalCommandDisplay,
sanitizeExecApprovalWarningText,
} from "./exec-approval-command-display.js";
import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-display.js";
import { formatExecApprovalExpiresIn } from "./exec-approval-reply.js";
import { sanitizeExecApprovalWarningText } from "./exec-approval-text-sanitize.js";
import {
resolveExecApprovalRequestAllowedDecisions,
type ExecApprovalRequest,
+5 -1
View File
@@ -267,6 +267,7 @@ describe("exec approval reply helpers", () => {
cwd: "/tmp/work",
host: "gateway",
nodeId: "node-1",
scope: { kind: "payment", amount: "49.99", currency: "EUR", target: "Stripe" },
expiresAtMs: 2500,
nowMs: 1000,
});
@@ -324,7 +325,9 @@ describe("exec approval reply helpers", () => {
expect(payload.text).toContain("Heads up.");
expect(payload.text).toContain("```txt\n/approve slug-1 allow-once\n```");
expect(payload.text).toContain("```sh\necho ok\n```");
expect(payload.text).toContain("Host: gateway\nNode: node-1\nCWD: /tmp/work\nExpires in: 2s");
expect(payload.text).toContain(
"Host: gateway\nNode: node-1\nCWD: /tmp/work\nScope: Pay 49.99 EUR to Stripe\nExpires in: 2s",
);
expect(payload.text).toContain("Full id: `req-1`");
});
@@ -352,6 +355,7 @@ describe("exec approval reply helpers", () => {
},
],
});
expect(payload.text).not.toContain("Scope:");
});
it("compacts structured cwd paths in pending reply payloads", () => {
+5
View File
@@ -14,6 +14,7 @@ import { formatHumanList } from "../shared/human-list.js";
// Builds reply payloads for exec approval prompts and outcomes.
import { formatFencedCodeBlock } from "../shared/markdown-code.js";
import { formatApprovalDisplayPath } from "./approval-display-paths.js";
import { summarizeApprovalScope, type ApprovalScope } from "./approval-scope.js";
import type { ChannelApprovalKind } from "./approval-types.js";
import {
describeNativeExecApprovalClientSetup,
@@ -68,6 +69,7 @@ export type ExecApprovalPendingReplyParams = {
cwd?: string;
host: ExecHost;
nodeId?: string;
scope?: ApprovalScope | null;
sessionKey?: string | null;
expiresAtMs?: number;
nowMs?: number;
@@ -431,6 +433,9 @@ export function buildExecApprovalPendingReplyPayload(
if (params.cwd) {
info.push(`CWD: ${formatApprovalDisplayPath(params.cwd)}`);
}
if (params.scope) {
info.push(`Scope: ${summarizeApprovalScope(params.scope)}`);
}
if (typeof params.expiresAtMs === "number" && Number.isFinite(params.expiresAtMs)) {
info.push(
`Expires in: ${formatExecApprovalExpiresIn(params.expiresAtMs, params.nowMs ?? Date.now())}`,
+201
View File
@@ -0,0 +1,201 @@
import { expectDefined } from "@openclaw/normalization-core";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
// Leaf sanitizer for approval display text; keep free of exec-approvals imports
// so approval-scope and exec-approvals-config can share it without a cycle.
import {
computeSensitiveRedactionBitmap,
redactSensitiveText,
resolveRedactOptions,
} from "../logging/redact.js";
// Escape control characters, Unicode format/line/paragraph separators, unpaired surrogates,
// and non-ASCII space separators that can spoof or break approval prompts in common UIs.
// With the Unicode regex flag, valid astral characters are full code points and do not match
// Cs; only malformed surrogate code units are escaped. Ordinary ASCII space stays unchanged.
const EXEC_APPROVAL_INVISIBLE_CHAR_REGEX =
/[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0]/gu;
const EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE =
/^[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0]$/u;
// Hard cap on input the sanitizer will process at all. Above this size we return a constant
// marker without running any regex work, so an attacker cannot force unbounded CPU/memory.
const EXEC_APPROVAL_MAX_INPUT = 256 * 1024;
// Soft cap on displayed output. Truncation happens AFTER redaction so a secret near the
// cutoff is not partially exposed when the cut lands mid-token below a pattern's minimum
// length (e.g. `ghp_` needs 20+ trailing chars before the `\b` match).
const EXEC_APPROVAL_MAX_OUTPUT = 16 * 1024;
const EXEC_APPROVAL_TRUNCATION_MARKER = "…[truncated]";
const EXEC_APPROVAL_OVERSIZED_MARKER =
"[exec approval command exceeds display size limit; full text suppressed]";
const EXEC_APPROVAL_WARNING_OVERSIZED_MARKER =
"[exec approval warning exceeds display size limit; full text suppressed]";
const BYPASS_MASK = "***";
function formatCodePointEscape(char: string): string {
return `\\u{${char.codePointAt(0)?.toString(16).toUpperCase() ?? "FFFD"}}`;
}
function normalizeDisplayLineBreaks(text: string): string {
return text.replace(/\r\n?/g, "\n").replace(/[\u2028\u2029]/g, "\n");
}
function escapeInvisibles(text: string, options?: { preserveLineBreaks?: boolean }): string {
return text.replace(EXEC_APPROVAL_INVISIBLE_CHAR_REGEX, (char) =>
options?.preserveLineBreaks && char === "\n" ? "\n" : formatCodePointEscape(char),
);
}
/** Sanitized approval text plus size-cap status for callers that need UI affordances. */
export type SanitizedExecApprovalDisplayText = {
/** Redacted, spoof-resistant command or warning text safe for an approval prompt. */
text: string;
/** True when sanitized output exceeded the display cap and was shortened. */
truncated: boolean;
/** True when raw input exceeded the hard cap and was replaced with a fixed marker. */
oversized: boolean;
};
function truncateForDisplay(text: string): SanitizedExecApprovalDisplayText {
if (text.length <= EXEC_APPROVAL_MAX_OUTPUT) {
return { text, truncated: false, oversized: false };
}
return {
text: truncateUtf16Safe(text, EXEC_APPROVAL_MAX_OUTPUT) + EXEC_APPROVAL_TRUNCATION_MARKER,
truncated: true,
oversized: false,
};
}
// Iterate by full Unicode code point so astral-plane invisibles (e.g. U+E0061 TAG LATIN
// SMALL LETTER A, category Cf) are matched as single characters instead of being seen as a
// surrogate pair whose halves are category Cs and would escape the invisible-char regex.
function buildStrippedView(original: string): { stripped: string; strippedToOrig: number[] } {
const strippedChars: string[] = [];
const strippedToOrig: number[] = [];
let offset = 0;
for (const cp of original) {
if (!EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE.test(cp)) {
strippedChars.push(cp);
for (let k = 0; k < cp.length; k++) {
strippedToOrig.push(offset + k);
}
}
offset += cp.length;
}
return { stripped: strippedChars.join(""), strippedToOrig };
}
function sanitizeExecApprovalDisplayTextInternal(
commandText: string,
options?: { preserveLineBreaks?: boolean; oversizedMarker?: string },
): SanitizedExecApprovalDisplayText {
if (commandText.length > EXEC_APPROVAL_MAX_INPUT) {
// Refuse to display inputs above the hard cap; anything larger must be approved through
// another channel. Running redaction on a multi-megabyte payload would be a DoS vector.
return {
text: options?.oversizedMarker ?? EXEC_APPROVAL_OVERSIZED_MARKER,
truncated: false,
oversized: true,
};
}
const rawRedacted = redactSensitiveText(commandText, { mode: "tools" });
const { stripped, strippedToOrig } = buildStrippedView(commandText);
const strippedRedacted = redactSensitiveText(stripped, { mode: "tools" });
// Fast path: stripping invisibles did not expose any additional secret-like content, so the
// raw-view redaction is sufficient. Preserve structure and show invisible-character spoof
// attempts as `\u{...}` escapes.
if (strippedRedacted === stripped) {
return truncateForDisplay(escapeInvisibles(rawRedacted, options));
}
// Detect bypass by position-bitmap coverage. Run the redaction matchers on both views and
// map stripped-view match positions back to original coordinates. If every position the
// stripped view would mask is also masked by the raw view, the raw view already covered
// everything — for example, an ordinary multi-line PEM private key where raw produces
// `BEGIN/…redacted…/END` while stripped collapses to `***`. A real bypass exists only when
// the stripped view masks at least one original position raw missed (e.g. the tail of an
// `sk-` token whose prefix-boundary was broken by a spliced zero-width or NBSP character).
const redaction = resolveRedactOptions({ mode: "tools" });
const rawMask = computeSensitiveRedactionBitmap(commandText, redaction);
const strippedMask = computeSensitiveRedactionBitmap(stripped, redaction);
let bypassDetected = false;
for (let i = 0; i < strippedMask.length; i++) {
if (
strippedMask[i] &&
!rawMask[expectDefined(strippedToOrig[i], "stripped to orig entry at i")]
) {
bypassDetected = true;
break;
}
}
if (!bypassDetected) {
return truncateForDisplay(escapeInvisibles(rawRedacted, options));
}
// Bypass path. Project the stripped-view mask back onto original positions, union with the
// raw-view mask, and emit a rendering where each contiguous masked run becomes a single
// `***` marker. Invisible characters that fall outside masked runs still render as visible
// `\u{...}` escapes so multi-line structure and spliced invisibles stay readable. The
// render loop advances by full code point so astral-plane invisibles are escaped as one
// `\u{...}` token rather than two separate surrogate escapes (or, worse, passed through
// unescaped because neither surrogate half matches the Cf regex).
const unionMask = rawMask.slice();
for (let i = 0; i < strippedMask.length; i++) {
if (strippedMask[i]) {
unionMask[expectDefined(strippedToOrig[i], "stripped to orig entry at i")] = true;
}
}
let out = "";
let i = 0;
while (i < commandText.length) {
if (unionMask[i]) {
let j = i;
while (j < commandText.length && unionMask[j]) {
j++;
}
out += BYPASS_MASK;
i = j;
continue;
}
const codePoint = commandText.codePointAt(i) ?? 0xfffd;
const cp = String.fromCodePoint(codePoint);
out +=
options?.preserveLineBreaks && cp === "\n"
? cp
: EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE.test(cp)
? formatCodePointEscape(cp)
: cp;
i += cp.length;
}
return truncateForDisplay(out);
}
/** Sanitizes exec command text for approval UI without exposing status metadata. */
export function sanitizeExecApprovalDisplayText(commandText: string): string {
return sanitizeExecApprovalDisplayTextInternal(commandText).text;
}
/**
* Sanitizes exec command text for approval UI and reports whether size caps changed it.
*/
export function sanitizeExecApprovalDisplayTextWithStatus(
commandText: string,
): SanitizedExecApprovalDisplayText {
return sanitizeExecApprovalDisplayTextInternal(commandText);
}
/**
* Sanitizes warning prose for approval UI while preserving real line boundaries.
*/
export function sanitizeExecApprovalWarningText(warningText: string): string {
return sanitizeExecApprovalWarningTextWithStatus(warningText).text;
}
/** Sanitizes warning prose and reports whether display bounds suppressed any content. */
export function sanitizeExecApprovalWarningTextWithStatus(
warningText: string,
): SanitizedExecApprovalDisplayText {
return sanitizeExecApprovalDisplayTextInternal(normalizeDisplayLineBreaks(warningText), {
preserveLineBreaks: true,
oversizedMarker: EXEC_APPROVAL_WARNING_OVERSIZED_MARKER,
});
}
+3
View File
@@ -1,5 +1,6 @@
// Shared exec approval types and mode normalization.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import type { ApprovalScope } from "./approval-scope.js";
import type { CommandExplanationSummary } from "./command-analysis/explain.js";
import type { ExecApprovalPolicySnapshot } from "./exec-approval-policy-snapshot.js";
import type { ExecAllowlistEntry } from "./exec-approvals.types.js";
@@ -195,6 +196,8 @@ export type ExecApprovalRequestPayload = {
security?: string | null;
ask?: string | null;
warningText?: string | null;
/** Owner-declared blast-radius facts; display-only, never authorization. */
scope?: ApprovalScope | null;
commandAnalysis?: CommandExplanationSummary | null;
commandSpans?: ExecApprovalCommandSpan[];
unavailableDecisions?: readonly ExecApprovalUnavailableDecision[];
+6
View File
@@ -1,5 +1,6 @@
// Defines plugin approval request/resolution payloads and actions.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { summarizeApprovalScope, type ApprovalScope } from "./approval-scope.js";
import type { ExecApprovalDecision } from "./exec-approvals.js";
// Plugin approval types and renderers mirror exec approval decisions while
@@ -20,6 +21,8 @@ export type PluginApprovalRequestPayload = {
description: string;
detail?: string | null;
severity?: "info" | "warning" | "critical" | null;
/** Owner-declared blast-radius facts; display-only, never authorization. */
scope?: ApprovalScope | null;
toolName?: string | null;
toolCallId?: string | null;
allowedDecisions?: readonly ExecApprovalDecision[] | null;
@@ -133,6 +136,9 @@ export function buildPluginApprovalRequestMessage(
lines.push(`Title: ${request.request.title}`);
// Reviewer-only detail stays off channel messages; channels receive the bounded description.
lines.push(`Description: ${request.request.description}`);
if (request.request.scope) {
lines.push(`Scope: ${summarizeApprovalScope(request.request.scope)}`);
}
if (request.request.toolName) {
lines.push(`Tool: ${request.request.toolName}`);
}
@@ -217,13 +217,22 @@ describe("plugin-sdk/approval-reaction-runtime", () => {
});
it("builds canonical exec reaction prompts without presentation controls", () => {
const payload = buildApprovalReactionPromptPayloadForRequest({
request: execRequest,
const content = buildApprovalReactionPendingContentForRequest({
request: {
...execRequest,
request: {
...execRequest.request,
scope: { kind: "payment", amount: "49.99", currency: "EUR", target: "Stripe" },
},
},
nowMs: 1_000,
});
const payload = content.reactionPayload;
expect(payload.text).toContain("**Exec approval required**\n**ID:** exec-approval-123");
expect(payload.text).toContain("**Pending command:**\n```sh\ntouch /tmp/foo\n```");
expect(payload.text).toContain("**Scope:** Pay 49.99 EUR to Stripe");
expect(content.manualFallbackPayload.text).toContain("Scope: Pay 49.99 EUR to Stripe");
expect(payload.text).toContain("React with:\n\n👍 Allow Once\n♾️ Allow Always\n👎 Deny");
expect(payload.text).toContain("Allow Once: /approve exec-approval-123 allow-once");
expect(payload.text).toContain("Allow Always: /approve exec-approval-123 allow-always");
@@ -287,6 +296,7 @@ describe("plugin-sdk/approval-reaction-runtime", () => {
request: {
...pluginRequest.request,
allowedDecisions: ["allow-once", "deny"],
scope: { kind: "external-post", target: "github", visibility: "public" },
},
},
nowMs: 1_000,
@@ -294,6 +304,7 @@ describe("plugin-sdk/approval-reaction-runtime", () => {
expect(payload.text).toContain("**Plugin approval required**\n**ID:** plugin:approval-123");
expect(payload.text).toContain("**Title:** Use 1Password");
expect(payload.text).toContain("**Scope:** Post publicly to github");
expect(payload.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
expect(payload.text).not.toContain("♾️ Allow Always");
expect(payload.text).toContain("Allow Once: /approve plugin:approval-123 allow-once");
@@ -1,5 +1,6 @@
import { sanitizeForPromptLiteral } from "../agents/sanitize-for-prompt.js";
import { formatApprovalDisplayPath } from "../infra/approval-display-paths.js";
import { summarizeApprovalScope } from "../infra/approval-scope.js";
import { normalizeApprovalRequest, type ChannelApprovalKind } from "../infra/approval-types.js";
import { buildPendingApprovalView } from "../infra/approval-view-model.js";
import type { ApprovalRequest, PendingApprovalView } from "../infra/approval-view-model.types.js";
@@ -308,6 +309,7 @@ function buildApprovalReactionPromptText(params: {
reactionHint: string | null;
}): string {
const { view } = params;
const scopeSummary = view.scope ? summarizeApprovalScope(view.scope) : undefined;
const allowedDecisions = listDecisionActions(view.actions);
const sections: string[] = [];
if (view.approvalKind === "exec") {
@@ -347,6 +349,9 @@ function buildApprovalReactionPromptText(params: {
if (view.nodeId) {
info.push(`**Node:** ${view.nodeId}`);
}
if (scopeSummary) {
info.push(`**Scope:** ${scopeSummary}`);
}
if (view.agentId) {
info.push(`**Agent:** ${view.agentId}`);
}
@@ -363,6 +368,9 @@ function buildApprovalReactionPromptText(params: {
if (view.description) {
details.push(`**Description:** ${view.description}`);
}
if (scopeSummary) {
details.push(`**Scope:** ${scopeSummary}`);
}
details.push(`**Severity:** ${formatSeverity(view.severity)}`);
if (view.toolName) {
details.push(`**Tool:** ${view.toolName}`);
@@ -504,6 +512,7 @@ export function buildApprovalReactionPendingContent(params: {
cwd: params.view.cwd ?? undefined,
host: params.view.host === "node" ? "node" : "gateway",
nodeId: params.view.nodeId ?? undefined,
scope: params.view.scope ?? undefined,
sessionKey: params.view.sessionKey ?? null,
expiresAtMs: request.expiresAtMs,
nowMs: params.nowMs,
+1
View File
@@ -1,5 +1,6 @@
// Approval request/reply helpers for exec and plugin approval flows.
export type { ApprovalScope } from "../infra/approval-scope.js";
export {
DEFAULT_EXEC_APPROVAL_TIMEOUT_MS,
resolveExecApprovalAllowedDecisions,
@@ -1,3 +1,5 @@
import type { ApprovalScope } from "../infra/approval-scope.js";
export const PluginApprovalResolutions = {
ALLOW_ONCE: "allow-once",
ALLOW_ALWAYS: "allow-always",
@@ -16,6 +18,7 @@ export type PluginHookBeforeToolCallResult = {
requireApproval?: {
title: string;
description: string;
scope?: ApprovalScope;
severity?: "info" | "warning" | "critical";
timeoutMs?: number;
/**
+2
View File
@@ -4,6 +4,7 @@ import type { Result } from "@openclaw/normalization-core/result";
import type { Command } from "commander";
import type { MessageReceipt } from "../channels/message/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ApprovalScope } from "../infra/approval-scope.js";
import type {
DiagnosticEventPrivateData,
DiagnosticEventInput,
@@ -221,6 +222,7 @@ type OpenClawPluginNodeInvokePolicyApprovalRuntime = {
request: (input: {
title: string;
description: string;
scope?: ApprovalScope;
severity?: "info" | "warning" | "critical";
toolName?: string;
toolCallId?: string;