Fix repeated Codex native approval prompts after allow-always (#78234)

* fix: reuse codex native approvals

* fix: scope native approval reuse by session

* fix: let codex guardian own native permission approvals

* fix: refresh plugin approval protocol models

---------

Co-authored-by: pashpashpash <nik@vault77.ai>
This commit is contained in:
Shakker
2026-05-06 21:55:38 +01:00
committed by GitHub
parent 97b07eaeaf
commit f011d6bc0a
18 changed files with 631 additions and 34 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
- Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro.
- MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13.
- Gateway/sessions: fast-path already-qualified model refs while building session-list rows so `openclaw sessions` and Control UI session lists avoid heavyweight model resolution on large stores. (#77902) Thanks @ragesaq.
- Codex/approvals: in Codex approval modes, stop installing the pre-guardian native `PermissionRequest` hook by default so Codex's reviewer can approve safe commands before OpenClaw surfaces an approval, remember `allow-always` decisions for identical Codex native `PermissionRequest` payloads within the active session window, and make plugin approval requests validate/render their actual allowed decisions so Telegram and other native approval UIs cannot offer stale actions. Thanks @shakkernerd.
- PR triage: mark external pull requests with `proof: supplied` when Barnacle finds structured real behavior proof, keep stale negative proof labels in sync across CRLF-edited PR bodies, and let ClawSweeper own the stronger `proof: sufficient` judgement.
- Sessions CLI: show the selected agent runtime in the `openclaw sessions` table so terminal output matches the runtime visibility already present in JSON/status surfaces. Thanks @vincentkoc.
- Talk/voice: unify realtime relay, transcription relay, managed-room handoff, Voice Call, Google Meet, VoiceClaw, and native clients around a shared Talk session controller and add the Gateway-managed `talk.session.*` RPC surface.

View File

@@ -5227,6 +5227,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
public let severity: String?
public let toolname: String?
public let toolcallid: String?
public let alloweddecisions: [String]?
public let agentid: String?
public let sessionkey: String?
public let turnsourcechannel: String?
@@ -5243,6 +5244,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
severity: String?,
toolname: String?,
toolcallid: String?,
alloweddecisions: [String]?,
agentid: String?,
sessionkey: String?,
turnsourcechannel: String?,
@@ -5258,6 +5260,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
self.severity = severity
self.toolname = toolname
self.toolcallid = toolcallid
self.alloweddecisions = alloweddecisions
self.agentid = agentid
self.sessionkey = sessionkey
self.turnsourcechannel = turnsourcechannel
@@ -5275,6 +5278,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
case severity
case toolname = "toolName"
case toolcallid = "toolCallId"
case alloweddecisions = "allowedDecisions"
case agentid = "agentId"
case sessionkey = "sessionKey"
case turnsourcechannel = "turnSourceChannel"

View File

@@ -5227,6 +5227,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
public let severity: String?
public let toolname: String?
public let toolcallid: String?
public let alloweddecisions: [String]?
public let agentid: String?
public let sessionkey: String?
public let turnsourcechannel: String?
@@ -5243,6 +5244,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
severity: String?,
toolname: String?,
toolcallid: String?,
alloweddecisions: [String]?,
agentid: String?,
sessionkey: String?,
turnsourcechannel: String?,
@@ -5258,6 +5260,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
self.severity = severity
self.toolname = toolname
self.toolcallid = toolcallid
self.alloweddecisions = alloweddecisions
self.agentid = agentid
self.sessionkey = sessionkey
self.turnsourcechannel = turnsourcechannel
@@ -5275,6 +5278,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
case severity
case toolname = "toolName"
case toolcallid = "toolCallId"
case alloweddecisions = "allowedDecisions"
case agentid = "agentId"
case sessionkey = "sessionKey"
case turnsourcechannel = "turnSourceChannel"

View File

@@ -944,9 +944,14 @@ The Codex harness has three hook layers:
OpenClaw does not use project or global Codex `hooks.json` files to route
OpenClaw plugin behavior. For the supported native tool and permission bridge,
OpenClaw injects per-thread Codex config for `PreToolUse`, `PostToolUse`,
`PermissionRequest`, and `Stop`. Other Codex hooks such as `SessionStart` and
`UserPromptSubmit` remain Codex-level controls; they are not exposed as
OpenClaw plugin hooks in the v1 contract.
`PermissionRequest`, and `Stop`. When Codex app-server approvals are enabled
(`approvalPolicy` is not `"never"`), the default injected native hook config
omits `PermissionRequest` so Codex's app-server reviewer and OpenClaw's approval
bridge handle real escalations after review. Operators can still explicitly add
`permission_request` to `nativeHookRelay.events` when they need the compatibility
relay. Other Codex hooks such as `SessionStart` and `UserPromptSubmit` remain
Codex-level controls; they are not exposed as OpenClaw plugin hooks in the v1
contract.
For OpenClaw dynamic tools, OpenClaw executes the tool after Codex asks for the
call, so OpenClaw fires the plugin and middleware behavior it owns in the
@@ -973,19 +978,19 @@ around that boundary.
Supported in Codex runtime v1:
| Surface | Support | Why |
| --------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI model loop through Codex | Supported | Codex app-server owns the OpenAI turn, native thread resume, and native tool continuation. |
| OpenClaw channel routing and delivery | Supported | Telegram, Discord, Slack, WhatsApp, iMessage, and other channels stay outside the model runtime. |
| OpenClaw dynamic tools | Supported | Codex asks OpenClaw to execute these tools, so OpenClaw stays in the execution path. |
| Prompt and context plugins | Supported | OpenClaw builds prompt overlays and projects context into the Codex turn before starting or resuming the thread. |
| Context engine lifecycle | Supported | Assemble, ingest or after-turn maintenance, and context-engine compaction coordination run for Codex turns. |
| Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. |
| Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. |
| Final-answer revision gate | Supported through the native hook relay | Codex `Stop` is relayed to `before_agent_finalize`; `revise` asks Codex for one more model pass before finalization. |
| Native shell, patch, and MCP block or observe | Supported through the native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for committed native tool surfaces, including MCP payloads on Codex app-server `0.125.0` or newer. Blocking is supported; argument rewriting is not. |
| Native permission policy | Supported through the native hook relay | Codex `PermissionRequest` can be routed through OpenClaw policy where the runtime exposes it. If OpenClaw returns no decision, Codex continues through its normal guardian or user approval path. |
| App-server trajectory capture | Supported | OpenClaw records the request it sent to app-server and the app-server notifications it receives. |
| Surface | Support | Why |
| --------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI model loop through Codex | Supported | Codex app-server owns the OpenAI turn, native thread resume, and native tool continuation. |
| OpenClaw channel routing and delivery | Supported | Telegram, Discord, Slack, WhatsApp, iMessage, and other channels stay outside the model runtime. |
| OpenClaw dynamic tools | Supported | Codex asks OpenClaw to execute these tools, so OpenClaw stays in the execution path. |
| Prompt and context plugins | Supported | OpenClaw builds prompt overlays and projects context into the Codex turn before starting or resuming the thread. |
| Context engine lifecycle | Supported | Assemble, ingest or after-turn maintenance, and context-engine compaction coordination run for Codex turns. |
| Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. |
| Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. |
| Final-answer revision gate | Supported through the native hook relay | Codex `Stop` is relayed to `before_agent_finalize`; `revise` asks Codex for one more model pass before finalization. |
| Native shell, patch, and MCP block or observe | Supported through the native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for committed native tool surfaces, including MCP payloads on Codex app-server `0.125.0` or newer. Blocking is supported; argument rewriting is not. |
| Native permission policy | Supported through Codex app-server approvals and the compatibility native hook relay | Codex app-server approval requests route through OpenClaw after Codex review. The `PermissionRequest` native hook relay is opt-in for native approval modes because Codex emits it before guardian review. |
| App-server trajectory capture | Supported | OpenClaw records the request it sent to app-server and the app-server notifications it receives. |
Not supported in Codex runtime v1:
@@ -1016,6 +1021,14 @@ it.
For `PermissionRequest`, OpenClaw only returns explicit allow or deny decisions
when policy decides. A no-decision result is not an allow. Codex treats it as no
hook decision and falls through to its own guardian or user approval path.
Codex app-server approval modes omit this native hook by default; this paragraph
applies when `permission_request` is explicitly included in
`nativeHookRelay.events` or a compatibility runtime installs it.
When an operator chooses `allow-always` for a Codex native permission request,
OpenClaw remembers that exact provider/session/tool input/cwd fingerprint for a
bounded session window. The remembered decision is intentionally exact-match
only: a changed command, arguments, tool payload, or cwd creates a fresh
approval.
Codex MCP tool approval elicitations are routed through OpenClaw's plugin
approval flow when Codex marks `_meta.codex_approval_kind` as

View File

@@ -233,6 +233,8 @@ The config shape is identical to `approvals.exec`: `enabled`, `mode`, `agentFilt
Channels that support shared interactive replies render the same approval buttons for both exec and
plugin approvals. Channels without shared interactive UI fall back to plain text with `/approve`
instructions.
Plugin approval requests may restrict the available decisions. Approval surfaces use the request's
declared decision set, and the Gateway rejects attempts to submit a decision that was not offered.
### Same-chat approvals on any channel

View File

@@ -364,13 +364,28 @@ type AppServerRequestHandler = (request: {
}) => Promise<unknown>;
function extractRelayIdFromThreadRequest(params: unknown): string {
const command = (
params as {
config?: {
"hooks.PreToolUse"?: Array<{ hooks?: Array<{ command?: string }> }>;
};
const config = (params as { config?: Record<string, unknown> }).config;
let command: string | undefined;
for (const key of [
"hooks.PreToolUse",
"hooks.PostToolUse",
"hooks.PermissionRequest",
"hooks.Stop",
]) {
const entries = config?.[key];
if (!Array.isArray(entries)) {
continue;
}
).config?.["hooks.PreToolUse"]?.[0]?.hooks?.[0]?.command;
for (const entry of entries as Array<{ hooks?: Array<{ command?: string }> }>) {
command = entry.hooks?.find((hook) => typeof hook.command === "string")?.command;
if (command) {
break;
}
}
if (command) {
break;
}
}
const match = command?.match(/--relay-id ([^ ]+)/);
if (!match?.[1]) {
throw new Error(`relay id missing from command: ${command}`);
@@ -1153,6 +1168,85 @@ describe("runCodexAppServerAttempt", () => {
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
});
it("lets Codex app-server approval modes own native permission requests by default", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
pluginConfig: {
appServer: {
mode: "guardian",
},
},
});
await harness.waitForMethod("turn/start");
const startRequest = harness.requests.find((request) => request.method === "thread/start");
expect(startRequest?.params).toEqual(
expect.objectContaining({
config: expect.objectContaining({
"features.codex_hooks": true,
"hooks.PreToolUse": expect.any(Array),
"hooks.PostToolUse": expect.any(Array),
"hooks.Stop": expect.any(Array),
}),
}),
);
expect(startRequest?.params).toEqual(
expect.objectContaining({
config: expect.not.objectContaining({
"hooks.PermissionRequest": expect.anything(),
}),
}),
);
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toMatchObject({
allowedEvents: ["pre_tool_use", "post_tool_use", "before_agent_finalize"],
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
});
it("preserves explicit native permission request relay events in app-server approval modes", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
pluginConfig: {
appServer: {
mode: "guardian",
},
},
nativeHookRelay: {
enabled: true,
events: ["permission_request"],
},
});
await harness.waitForMethod("turn/start");
const startRequest = harness.requests.find((request) => request.method === "thread/start");
expect(startRequest?.params).toEqual(
expect.objectContaining({
config: expect.objectContaining({
"features.codex_hooks": true,
"hooks.PermissionRequest": expect.any(Array),
}),
}),
);
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toMatchObject({
allowedEvents: ["permission_request"],
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
});
it("reuses the Codex native hook relay id across runs for the same session", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -60,6 +60,7 @@ import { ensureCodexComputerUse } from "./computer-use.js";
import {
readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
type CodexAppServerRuntimeOptions,
type CodexPluginConfig,
} from "./config.js";
import { projectContextEngineAssemblyForCodex } from "./context-engine-projection.js";
@@ -121,6 +122,8 @@ const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000;
const CODEX_STEER_ALL_DEBOUNCE_MS = 500;
const LOG_FIELD_MAX_LENGTH = 160;
const CODEX_NATIVE_PROJECT_DOC_BASENAMES = new Set(["agents.md"]);
const CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS =
CODEX_NATIVE_HOOK_RELAY_EVENTS.filter((event) => event !== "permission_request");
const CODEX_BOOTSTRAP_CONTEXT_ORDER = new Map<string, number>([
["soul.md", 10],
["identity.md", 20],
@@ -370,6 +373,10 @@ export async function runCodexAppServerAttempt(
const attemptClientFactory = resolveCodexAppServerClientFactory();
const pluginConfig = readCodexPluginConfig(options.pluginConfig);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const nativeHookRelayEvents = resolveCodexNativeHookRelayEvents({
configuredEvents: options.nativeHookRelay?.events,
appServer,
});
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
await fs.mkdir(resolvedWorkspace, { recursive: true });
const sandboxSessionKey =
@@ -574,6 +581,7 @@ export async function runCodexAppServerAttempt(
});
nativeHookRelay = createCodexNativeHookRelay({
options: options.nativeHookRelay,
events: nativeHookRelayEvents,
agentId: sessionAgentId,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
@@ -584,7 +592,7 @@ export async function runCodexAppServerAttempt(
const nativeHookRelayConfig = nativeHookRelay
? buildCodexNativeHookRelayConfig({
relay: nativeHookRelay,
events: options.nativeHookRelay?.events,
events: nativeHookRelayEvents,
hookTimeoutSec: options.nativeHookRelay?.hookTimeoutSec,
})
: options.nativeHookRelay?.enabled === false
@@ -1439,11 +1447,11 @@ function createCodexNativeHookRelay(params: {
options:
| {
enabled?: boolean;
events?: readonly NativeHookRelayEvent[];
ttlMs?: number;
gatewayTimeoutMs?: number;
}
| undefined;
events: readonly NativeHookRelayEvent[];
agentId: string | undefined;
sessionId: string;
sessionKey: string | undefined;
@@ -1466,7 +1474,7 @@ function createCodexNativeHookRelay(params: {
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.config ? { config: params.config } : {}),
runId: params.runId,
allowedEvents: params.options?.events ?? CODEX_NATIVE_HOOK_RELAY_EVENTS,
allowedEvents: params.events,
ttlMs: params.options?.ttlMs,
signal: params.signal,
command: {
@@ -1475,6 +1483,22 @@ function createCodexNativeHookRelay(params: {
});
}
function resolveCodexNativeHookRelayEvents(params: {
configuredEvents?: readonly NativeHookRelayEvent[];
appServer: Pick<CodexAppServerRuntimeOptions, "approvalPolicy">;
}): readonly NativeHookRelayEvent[] {
if (params.configuredEvents?.length) {
return params.configuredEvents;
}
// Codex emits PermissionRequest before the app-server approval reviewer has
// resolved the command. In native approval modes, let Codex's app-server
// approval bridge own the real escalation instead of surfacing a stale
// pre-guardian OpenClaw plugin approval prompt.
return params.appServer.approvalPolicy === "never"
? CODEX_NATIVE_HOOK_RELAY_EVENTS
: CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS;
}
function buildCodexNativeHookRelayId(params: {
agentId: string | undefined;
sessionId: string;

View File

@@ -1178,6 +1178,170 @@ describe("native hook relay registry", () => {
);
});
it("reuses allow-always PermissionRequest approvals for identical relay content", async () => {
const relay = registerNativeHookRelay({
provider: "codex",
relayId: "codex-stable-permission-cache",
sessionId: "session-1",
runId: "run-1",
});
const approvalRequester = vi.fn(async () => "allow-always" as const);
__testing.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester);
const first = await invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "permission_request",
rawPayload: {
hook_event_name: "PermissionRequest",
cwd: "/repo",
tool_name: "Bash",
tool_use_id: "native-call-1",
tool_input: { command: "browserforce tabs" },
},
});
relay.unregister();
registerNativeHookRelay({
provider: "codex",
relayId: "codex-stable-permission-cache",
sessionId: "session-1",
runId: "run-2",
});
const second = await invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "permission_request",
rawPayload: {
hook_event_name: "PermissionRequest",
cwd: "/repo",
tool_name: "Bash",
tool_use_id: "native-call-2",
tool_input: { command: "browserforce tabs" },
},
});
expect(approvalRequester).toHaveBeenCalledTimes(1);
expect([first, second].map((response) => JSON.parse(response.stdout))).toEqual([
{
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: { behavior: "allow" },
},
},
{
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: { behavior: "allow" },
},
},
]);
});
it("does not reuse allow-always PermissionRequest approvals across sessions with the same relay id", async () => {
const relayId = "codex-stable-permission-cache-cross-session";
const first = registerNativeHookRelay({
provider: "codex",
relayId,
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
});
const approvalRequester = vi.fn(async () => "allow-always" as const);
__testing.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester);
await invokeNativeHookRelay({
provider: "codex",
relayId: first.relayId,
event: "permission_request",
rawPayload: {
hook_event_name: "PermissionRequest",
cwd: "/repo",
tool_name: "Bash",
tool_use_id: "native-call-1",
tool_input: { command: "browserforce tabs" },
},
});
first.unregister();
const second = registerNativeHookRelay({
provider: "codex",
relayId,
agentId: "agent-1",
sessionId: "session-2",
sessionKey: "agent:main:session-2",
runId: "run-2",
});
await invokeNativeHookRelay({
provider: "codex",
relayId: second.relayId,
event: "permission_request",
rawPayload: {
hook_event_name: "PermissionRequest",
cwd: "/repo",
tool_name: "Bash",
tool_use_id: "native-call-2",
tool_input: { command: "browserforce tabs" },
},
});
expect(approvalRequester).toHaveBeenCalledTimes(2);
expect(approvalRequester).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
agentId: "agent-1",
sessionId: "session-2",
sessionKey: "agent:main:session-2",
toolInput: { command: "browserforce tabs" },
}),
);
});
it("keeps allow-always PermissionRequest reuse scoped to matching cwd and input", async () => {
const relay = registerNativeHookRelay({
provider: "codex",
sessionId: "session-1",
runId: "run-1",
});
const approvalRequester = vi.fn(async () => "allow-always" as const);
__testing.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester);
await invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "permission_request",
rawPayload: {
hook_event_name: "PermissionRequest",
cwd: "/repo-a",
tool_name: "Bash",
tool_input: { command: "npm test" },
},
});
await invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "permission_request",
rawPayload: {
hook_event_name: "PermissionRequest",
cwd: "/repo-b",
tool_name: "Bash",
tool_input: { command: "npm test" },
},
});
await invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "permission_request",
rawPayload: {
hook_event_name: "PermissionRequest",
cwd: "/repo-a",
tool_name: "Bash",
tool_input: { command: "npm test -- --changed" },
},
});
expect(approvalRequester).toHaveBeenCalledTimes(3);
});
it("defers PermissionRequest when OpenClaw approval does not decide", async () => {
__testing.setNativeHookRelayPermissionApprovalRequesterForTests(
vi.fn(async () => "defer" as const),

View File

@@ -151,6 +151,7 @@ type NativeHookRelayProviderAdapter = {
const DEFAULT_RELAY_TTL_MS = 30 * 60 * 1000;
const DEFAULT_RELAY_TIMEOUT_MS = 5_000;
const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000;
const PERMISSION_ALLOW_ALWAYS_TTL_MS = 30 * 60 * 1000;
const MAX_NATIVE_HOOK_RELAY_INVOCATIONS = 200;
const MAX_NATIVE_HOOK_RELAY_JSON_DEPTH = 64;
const MAX_NATIVE_HOOK_RELAY_JSON_NODES = 20_000;
@@ -167,6 +168,7 @@ const MAX_APPROVAL_TITLE_LENGTH = 80;
const MAX_APPROVAL_DESCRIPTION_LENGTH = 700;
const MAX_PERMISSION_APPROVALS_PER_WINDOW = 12;
const PERMISSION_APPROVAL_WINDOW_MS = 60_000;
const MAX_PERMISSION_ALLOW_ALWAYS_ENTRIES = 512;
const MAX_NATIVE_HOOK_BRIDGE_BODY_BYTES = 5_000_000;
const MAX_NATIVE_HOOK_BRIDGE_RESPONSE_BYTES = 5_000_000;
const NATIVE_HOOK_BRIDGE_RETRY_INTERVAL_MS = 25;
@@ -179,11 +181,15 @@ const pendingPermissionApprovals = new Map<
Promise<NativeHookRelayPermissionApprovalResult>
>();
const permissionApprovalWindows = new Map<string, number[]>();
const permissionAllowAlwaysApprovals = new Map<string, { expiresAtMs: number }>();
const log = createSubsystemLogger("agents/harness/native-hook-relay");
type NativeHookRelayPermissionDecision = "allow" | "deny";
type NativeHookRelayPermissionApprovalResult = NativeHookRelayPermissionDecision | "defer";
type NativeHookRelayPermissionApprovalResult =
| NativeHookRelayPermissionDecision
| "allow-always"
| "defer";
type NativeHookRelayPermissionApprovalRequest = {
provider: NativeHookRelayProvider;
@@ -285,6 +291,7 @@ export function registerNativeHookRelay(
params: RegisterNativeHookRelayParams,
): NativeHookRelayRegistrationHandle {
pruneExpiredNativeHookRelays();
pruneNativeHookRelayPermissionAllowAlways();
const relayId = normalizeRelayId(params.relayId) ?? randomUUID();
const allowedEvents = normalizeAllowedEvents(params.allowedEvents);
unregisterNativeHookRelay(relayId);
@@ -921,6 +928,13 @@ async function runNativeHookRelayPermissionRequest(params: {
registration: params.registration,
request,
});
const allowAlwaysKey = nativeHookRelayPermissionAllowAlwaysKey({
registration: params.registration,
request,
});
if (hasNativeHookRelayPermissionAllowAlways(allowAlwaysKey)) {
return params.adapter.renderPermissionDecisionResponse("allow");
}
const pendingApproval = pendingPermissionApprovals.get(approvalKey);
try {
const decision = await (pendingApproval ??
@@ -932,6 +946,10 @@ async function runNativeHookRelayPermissionRequest(params: {
if (decision === "allow") {
return params.adapter.renderPermissionDecisionResponse("allow");
}
if (decision === "allow-always") {
rememberNativeHookRelayPermissionAllowAlways(allowAlwaysKey);
return params.adapter.renderPermissionDecisionResponse("allow");
}
if (decision === "deny") {
return params.adapter.renderPermissionDecisionResponse("deny", "Denied by user");
}
@@ -1017,6 +1035,25 @@ function nativeHookRelayPermissionApprovalKey(params: {
].join(":");
}
function nativeHookRelayPermissionAllowAlwaysKey(params: {
registration: NativeHookRelayRegistration;
request: NativeHookRelayPermissionApprovalRequest;
}): string {
const hash = createHash("sha256");
hash.update("openclaw:native-hook-relay:permission-allow-always:v2");
hash.update("\0");
hash.update(params.registration.relayId);
hash.update("\0");
hash.update(params.request.provider);
hash.update("\0");
hash.update(params.request.agentId ?? "");
hash.update("\0");
hash.update(params.request.sessionKey ?? params.request.sessionId);
hash.update("\0");
hash.update(permissionRequestContentFingerprint(params.request));
return hash.digest("hex");
}
function permissionRequestFallbackKey(request: NativeHookRelayPermissionApprovalRequest): string {
const command = readOptionalString(request.toolInput.command);
if (command) {
@@ -1049,6 +1086,8 @@ function permissionRequestContentFingerprint(
const hash = createHash("sha256");
hash.update(request.toolName);
hash.update("\0");
hash.update(request.cwd ?? "");
hash.update("\0");
updateJsonHash(hash, request.toolInput);
return hash.digest("hex");
}
@@ -1140,6 +1179,40 @@ function consumeNativeHookRelayPermissionBudget(relayId: string, now = Date.now(
return true;
}
function hasNativeHookRelayPermissionAllowAlways(key: string, now = Date.now()): boolean {
const entry = permissionAllowAlwaysApprovals.get(key);
if (!entry) {
return false;
}
if (entry.expiresAtMs <= now) {
permissionAllowAlwaysApprovals.delete(key);
return false;
}
return true;
}
function rememberNativeHookRelayPermissionAllowAlways(key: string, now = Date.now()): void {
pruneNativeHookRelayPermissionAllowAlways(now);
permissionAllowAlwaysApprovals.set(key, {
expiresAtMs: now + PERMISSION_ALLOW_ALWAYS_TTL_MS,
});
while (permissionAllowAlwaysApprovals.size > MAX_PERMISSION_ALLOW_ALWAYS_ENTRIES) {
const oldestKey = permissionAllowAlwaysApprovals.keys().next().value;
if (typeof oldestKey !== "string") {
break;
}
permissionAllowAlwaysApprovals.delete(oldestKey);
}
}
function pruneNativeHookRelayPermissionAllowAlways(now = Date.now()): void {
for (const [key, entry] of permissionAllowAlwaysApprovals) {
if (entry.expiresAtMs <= now) {
permissionAllowAlwaysApprovals.delete(key);
}
}
}
function removeNativeHookRelayPermissionState(relayId: string): void {
permissionApprovalWindows.delete(relayId);
for (const key of pendingPermissionApprovals.keys()) {
@@ -1316,6 +1389,11 @@ async function requestNativeHookRelayPermissionApproval(
severity: "warning",
toolName: request.toolName,
toolCallId: request.toolCallId,
allowedDecisions: [
PluginApprovalResolutions.ALLOW_ONCE,
PluginApprovalResolutions.ALLOW_ALWAYS,
PluginApprovalResolutions.DENY,
],
agentId: request.agentId,
sessionKey: request.sessionKey,
timeoutMs,
@@ -1338,12 +1416,12 @@ async function requestNativeHookRelayPermissionApproval(
});
decision = waitResult?.decision;
}
if (
decision === PluginApprovalResolutions.ALLOW_ONCE ||
decision === PluginApprovalResolutions.ALLOW_ALWAYS
) {
if (decision === PluginApprovalResolutions.ALLOW_ONCE) {
return "allow";
}
if (decision === PluginApprovalResolutions.ALLOW_ALWAYS) {
return "allow-always";
}
if (decision === PluginApprovalResolutions.DENY) {
return "deny";
}
@@ -1629,6 +1707,7 @@ export const __testing = {
invocations.length = 0;
pendingPermissionApprovals.clear();
permissionApprovalWindows.clear();
permissionAllowAlwaysApprovals.clear();
nativeHookRelayPermissionApprovalRequester = requestNativeHookRelayPermissionApproval;
},
getNativeHookRelayInvocationsForTests(): NativeHookRelayInvocation[] {

View File

@@ -14,6 +14,12 @@ export const PluginApprovalRequestParamsSchema = Type.Object(
severity: Type.Optional(Type.String({ enum: ["info", "warning", "critical"] })),
toolName: Type.Optional(Type.String()),
toolCallId: Type.Optional(Type.String()),
allowedDecisions: Type.Optional(
Type.Array(Type.String({ enum: ["allow-once", "allow-always", "deny"] }), {
minItems: 1,
maxItems: 3,
}),
),
agentId: Type.Optional(Type.String()),
sessionKey: Type.Optional(Type.String()),
turnSourceChannel: Type.Optional(Type.String()),

View File

@@ -322,6 +322,40 @@ describe("createPluginApprovalHandlers", () => {
expect.objectContaining({ message: expect.stringContaining("unexpected property") }),
);
});
it("stores scoped allowed decisions on plugin approval requests", async () => {
const handlers = createPluginApprovalHandlers(manager);
const respond = vi.fn();
const opts = createMockOptions(
"plugin.approval.request",
{
title: "T",
description: "D",
allowedDecisions: ["allow-once", "deny", "allow-once"],
twoPhase: true,
},
{ respond },
);
const handlerPromise = handlers["plugin.approval.request"](opts);
await vi.waitFor(() => {
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ status: "accepted", id: expect.any(String) }),
undefined,
);
});
const acceptedCall = respond.mock.calls.find(
(call) => (call[1] as Record<string, unknown>)?.status === "accepted",
);
const approvalId = (acceptedCall?.[1] as Record<string, unknown>)?.id as string;
expect(manager.getSnapshot(approvalId)?.request).toMatchObject({
allowedDecisions: ["allow-once", "deny"],
});
manager.resolve(approvalId, "deny");
await handlerPromise;
});
});
describe("plugin.approval.list", () => {
@@ -463,6 +497,35 @@ describe("createPluginApprovalHandlers", () => {
);
});
it("rejects decisions outside plugin approval allowed decisions", async () => {
const handlers = createPluginApprovalHandlers(manager);
const record = manager.create(
{
title: "T",
description: "D",
allowedDecisions: ["allow-once", "deny"],
},
60_000,
);
void manager.register(record, 60_000);
const opts = createMockOptions("plugin.approval.resolve", {
id: record.id,
decision: "allow-always",
});
await handlers["plugin.approval.resolve"](opts);
expect(opts.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message: "allow-always is unavailable for this plugin approval",
details: { allowedDecisions: ["allow-once", "deny"] },
}),
);
expect(manager.getSnapshot(record.id)?.decision).toBeUndefined();
});
it("rejects unknown approval id", async () => {
const handlers = createPluginApprovalHandlers(manager);
const opts = createMockOptions("plugin.approval.resolve", {

View File

@@ -5,6 +5,7 @@ import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.
import {
DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS,
MAX_PLUGIN_APPROVAL_TIMEOUT_MS,
resolvePluginApprovalRequestAllowedDecisions,
} from "../../infra/plugin-approvals.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import type { ExecApprovalManager } from "../exec-approval-manager.js";
@@ -61,6 +62,7 @@ export function createPluginApprovalHandlers(
severity?: string | null;
toolName?: string | null;
toolCallId?: string | null;
allowedDecisions?: string[] | null;
agentId?: string | null;
sessionKey?: string | null;
turnSourceChannel?: string | null;
@@ -86,6 +88,13 @@ export function createPluginApprovalHandlers(
severity: (p.severity as PluginApprovalRequestPayload["severity"]) ?? null,
toolName: p.toolName ?? null,
toolCallId: p.toolCallId ?? null,
...(Array.isArray(p.allowedDecisions)
? {
allowedDecisions: resolvePluginApprovalRequestAllowedDecisions({
allowedDecisions: p.allowedDecisions,
}),
}
: {}),
agentId: p.agentId ?? null,
sessionKey: p.sessionKey ?? null,
turnSourceChannel: normalizeTrimmedString(p.turnSourceChannel),
@@ -166,14 +175,24 @@ export function createPluginApprovalHandlers(
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
return;
}
const decision = p.decision;
await handleApprovalResolve({
manager,
inputId: p.id,
decision: p.decision,
decision,
respond,
context,
client,
exposeAmbiguousPrefixError: false,
validateDecision: (snapshot) =>
resolvePluginApprovalRequestAllowedDecisions(snapshot.request).includes(decision)
? null
: {
message: `${decision} is unavailable for this plugin approval`,
details: {
allowedDecisions: resolvePluginApprovalRequestAllowedDecisions(snapshot.request),
},
},
resolvedEventName: "plugin.approval.resolved",
buildResolvedEvent: ({ approvalId, decision, resolvedBy, snapshot, nowMs }) => ({
id: approvalId,

View File

@@ -14,7 +14,10 @@ import {
resolveExecApprovalRequestAllowedDecisions,
type ExecApprovalRequest,
} from "./exec-approvals.js";
import type { PluginApprovalRequest } from "./plugin-approvals.js";
import {
resolvePluginApprovalRequestAllowedDecisions,
type PluginApprovalRequest,
} from "./plugin-approvals.js";
type ApprovalPhase = "pending" | "resolved" | "expired";
@@ -105,6 +108,7 @@ export function buildPendingApprovalView(request: ApprovalRequest): PendingAppro
...buildPluginViewBase(pluginRequest, "pending"),
actions: buildExecApprovalActionDescriptors({
approvalCommandId: pluginRequest.id,
allowedDecisions: resolvePluginApprovalRequestAllowedDecisions(pluginRequest.request),
}),
expiresAtMs: pluginRequest.expiresAtMs,
};

View File

@@ -38,6 +38,7 @@ import {
approvalDecisionLabel,
buildPluginApprovalExpiredMessage,
buildPluginApprovalRequestMessage,
resolvePluginApprovalRequestAllowedDecisions,
type PluginApprovalRequest,
type PluginApprovalResolved,
} from "./plugin-approvals.js";
@@ -461,6 +462,7 @@ function buildPluginPendingPayload(params: {
request: params.request,
nowMs: params.nowMs,
text: buildPluginApprovalRequestMessage(params.request, params.nowMs),
allowedDecisions: resolvePluginApprovalRequestAllowedDecisions(params.request.request),
}),
});
}

View File

@@ -154,6 +154,46 @@ describe("plugin approval forwarding", () => {
});
});
it("renders only request-scoped plugin approval decisions", async () => {
const deliver = vi.fn().mockResolvedValue([]);
const { forwarder } = createForwarder({ cfg: PLUGIN_TARGETS_CFG, deliver });
const result = await forwarder.handlePluginApprovalRequested!(
makePluginRequest({
request: {
...makePluginRequest().request,
allowedDecisions: ["allow-once", "deny"],
},
}),
);
expect(result).toBe(true);
await flushPendingDelivery();
const deliveryArgs = deliver.mock.calls[0]?.[0] as
| { payloads?: Array<{ text?: string; interactive?: unknown }> }
| undefined;
const payload = deliveryArgs?.payloads?.[0];
expect(payload?.text).toContain("Reply with: /approve <id> allow-once|deny");
expect(payload?.text).not.toContain("allow-always");
expect(payload?.interactive).toEqual({
blocks: [
{
type: "buttons",
buttons: [
{
label: "Allow Once",
value: "/approve plugin-req-1 allow-once",
style: "success",
},
{
label: "Deny",
value: "/approve plugin-req-1 deny",
style: "danger",
},
],
},
],
});
});
it("includes severity icon for critical", async () => {
const deliver = vi.fn().mockResolvedValue([]);
const { forwarder } = createForwarder({ cfg: PLUGIN_TARGETS_CFG, deliver });

View File

@@ -7,6 +7,7 @@ export type PluginApprovalRequestPayload = {
severity?: "info" | "warning" | "critical" | null;
toolName?: string | null;
toolCallId?: string | null;
allowedDecisions?: readonly ExecApprovalDecision[] | null;
agentId?: string | null;
sessionKey?: string | null;
turnSourceChannel?: string | null;
@@ -34,6 +35,11 @@ export const DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS = 120_000;
export const MAX_PLUGIN_APPROVAL_TIMEOUT_MS = 600_000;
export const PLUGIN_APPROVAL_TITLE_MAX_LENGTH = 80;
export const PLUGIN_APPROVAL_DESCRIPTION_MAX_LENGTH = 256;
export const DEFAULT_PLUGIN_APPROVAL_DECISIONS = [
"allow-once",
"allow-always",
"deny",
] as const satisfies readonly ExecApprovalDecision[];
export function approvalDecisionLabel(decision: ExecApprovalDecision): string {
if (decision === "allow-once") {
@@ -45,6 +51,23 @@ export function approvalDecisionLabel(decision: ExecApprovalDecision): string {
return "denied";
}
export function resolvePluginApprovalRequestAllowedDecisions(params?: {
allowedDecisions?: readonly ExecApprovalDecision[] | readonly string[] | null;
}): readonly ExecApprovalDecision[] {
const explicit: ExecApprovalDecision[] = [];
if (Array.isArray(params?.allowedDecisions)) {
for (const decision of params.allowedDecisions) {
if (
(decision === "allow-once" || decision === "allow-always" || decision === "deny") &&
!explicit.includes(decision)
) {
explicit.push(decision);
}
}
}
return explicit.length > 0 ? explicit : DEFAULT_PLUGIN_APPROVAL_DECISIONS;
}
export function buildPluginApprovalRequestMessage(
request: PluginApprovalRequest,
nowMsValue: number,
@@ -67,7 +90,11 @@ export function buildPluginApprovalRequestMessage(
lines.push(`ID: ${request.id}`);
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMsValue) / 1000));
lines.push(`Expires in: ${expiresIn}s`);
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
lines.push(
`Reply with: /approve <id> ${resolvePluginApprovalRequestAllowedDecisions(request.request).join(
"|",
)}`,
);
return lines.join("\n");
}

View File

@@ -102,6 +102,54 @@ describe("plugin-sdk/approval-renderers", () => {
},
},
},
{
name: "builds plugin pending payloads with request-scoped decisions",
payload: buildPluginApprovalPendingReplyPayload({
request: {
id: "plugin-approval-123",
request: {
title: "Sensitive action",
description: "Needs approval",
allowedDecisions: ["allow-once", "deny"],
},
createdAtMs: 1_000,
expiresAtMs: 61_000,
},
nowMs: 1_000,
}),
textExpected: (text: string) =>
expect(text).toContain("Reply with: /approve <id> allow-once|deny"),
interactiveExpected: {
blocks: [
{
type: "buttons",
buttons: [
{
label: "Allow Once",
value: "/approve plugin-approval-123 allow-once",
style: "success",
},
{
label: "Deny",
value: "/approve plugin-approval-123 deny",
style: "danger",
},
],
},
],
},
channelDataExpected: {
execApproval: {
agentId: undefined,
approvalId: "plugin-approval-123",
approvalKind: "plugin",
approvalSlug: "plugin-a",
allowedDecisions: ["allow-once", "deny"],
sessionKey: undefined,
state: "pending",
},
},
},
{
name: "builds generic resolved payloads with approval metadata",
payload: buildApprovalResolvedReplyPayload({

View File

@@ -5,6 +5,7 @@ import {
import {
buildPluginApprovalRequestMessage,
buildPluginApprovalResolvedMessage,
resolvePluginApprovalRequestAllowedDecisions,
type PluginApprovalRequest,
type PluginApprovalResolved,
} from "../infra/plugin-approvals.js";
@@ -77,7 +78,9 @@ export function buildPluginApprovalPendingReplyPayload(params: {
approvalId: params.request.id,
approvalSlug: params.approvalSlug ?? params.request.id.slice(0, 8),
text: params.text ?? buildPluginApprovalRequestMessage(params.request, params.nowMs),
allowedDecisions: params.allowedDecisions,
allowedDecisions:
params.allowedDecisions ??
resolvePluginApprovalRequestAllowedDecisions(params.request.request),
channelData: params.channelData,
});
}