feat: add user input blocking lifecycle gates (#75035)

Summary:
- The PR adds a `before_agent_run` plugin hook with pass/block decisions, redacted blocked-turn persistence, diagnostics/docs/changelog updates, and focused runner, gateway, session, and plugin tests.
- Reproducibility: not applicable. as a feature PR rather than a current-main bug report. Current main lacks ` ... un`, while the PR head adds source coverage and copied live Gateway/WebChat log proof for the new behavior.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix: trim before agent hook PR scope
- PR branch already contained follow-up commit before automerge: fix: keep before-agent blocks redacted
- PR branch already contained follow-up commit before automerge: fix: keep runtime context out of model prompt
- PR branch already contained follow-up commit before automerge: docs: refresh config baseline after rebase
- PR branch already contained follow-up commit before automerge: fix: align blocked turn clients with redacted content
- PR branch already contained follow-up commit before automerge: fix: remove out-of-scope client block UI changes

Validation:
- ClawSweeper review passed for head 767e46fde8.
- Required merge gates passed before the squash merge.

Prepared head SHA: 767e46fde8
Review: https://github.com/openclaw/openclaw/pull/75035#issuecomment-4351843275

Co-authored-by: Jesse Merhi <jessejmerhi@gmail.com>
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
Jesse Merhi
2026-05-06 21:41:04 +10:00
committed by GitHub
parent 2915f45233
commit 1c42c77433
48 changed files with 2194 additions and 166 deletions

View File

@@ -109,6 +109,7 @@ Docs: https://docs.openclaw.ai
- Developer tooling: add checked-in VS Code Gateway debugging configs and an opt-in `OUTPUT_SOURCE_MAPS=1` source-map build path for breakpoints in TypeScript source. (#45710) Thanks @SwissArmyBud.
- Managed proxy: add `proxy.loopbackMode` for Gateway loopback control-plane traffic, allowing operators to keep the default Gateway loopback bypass, force loopback Gateway traffic through the proxy, or block it. (#77018) Thanks @jesse-merhi.
- Telegram/native commands: show the current thinking level above the `/think` level picker so users can see the active setting before changing it. (#78278) Thanks @obviyus.
- Plugins/hooks: add a `before_agent_run` pass/block gate that can stop a user prompt before model submission while preserving a redacted transcript entry for the user, and clarify that raw conversation hooks require `hooks.allowConversationAccess=true`. (#75035) Thanks @jesse-merhi.
### Fixes

View File

@@ -1,4 +1,4 @@
5dd302a20b8a6347425617323d0ad7875f9b7631acd3ed3935cfaaf7708a32dd config-baseline.json
d192d678668712b81cc2e76ddcb6420893ab5144944ccb830b290019d6a717a4 config-baseline.core.json
da2ba9afd1062db1fafe81fb42e39db4ad65995a5e56caef4057a9954c2c386b config-baseline.json
f860a7d43d3bd15379d8c3dfccbc6fcbf47b9bec8d8b67b29dd7313946905645 config-baseline.core.json
cd7c0c7fb1435bc7e59099e9ac334462d5ad444016e9ab4512aae63a238f78dc config-baseline.channel.json
6871e789b74722e4ff2c877940dac256c232433ae26b305fc6ca782b90662097 config-baseline.plugin.json
2fee9c16a60d074fac428b4ad14c38ad3ca7febefacfad819f741a820101326e config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
ce3eef3355f00b88eba1dd54731f932a1ffff9dee64cb19402d7d89b2c363681 plugin-sdk-api-baseline.json
28eb08edb11108d80ec5d5bd12c97108495b064a4d6dd5ca3ecc01d12c2d4c42 plugin-sdk-api-baseline.jsonl
fe1d0d9bde4ab216a92e4fce8c01d699601c3f033f6449352382ff902b129a6f plugin-sdk-api-baseline.json
15eb9a88276f066cddb645f072f57f25f1ff59174cf2277b6014ca2565dbe09a plugin-sdk-api-baseline.jsonl

View File

@@ -275,7 +275,7 @@ For runtime hook debugging:
- `openclaw plugins inspect <id> --runtime --json` shows registered hooks and diagnostics from a module-loaded inspection pass. Runtime inspection never installs dependencies; use `openclaw doctor --fix` to clean legacy dependency state or recover missing downloadable plugins that are referenced by config.
- `openclaw gateway status --deep --require-rpc` confirms the reachable Gateway, service/process hints, config path, and RPC health.
- Non-bundled conversation hooks (`llm_input`, `llm_output`, `before_agent_finalize`, `agent_end`) require `plugins.entries.<id>.hooks.allowConversationAccess=true`.
- Non-bundled conversation hooks (`llm_input`, `llm_output`, `before_model_resolve`, `before_agent_reply`, `before_agent_run`, `before_agent_finalize`, `agent_end`) require `plugins.entries.<id>.hooks.allowConversationAccess=true`.
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):

View File

@@ -195,7 +195,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
- `plugins.entries.<id>.apiKey`: plugin-level API key convenience field (when supported by the plugin).
- `plugins.entries.<id>.env`: plugin-scoped env var map.
- `plugins.entries.<id>.hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories.
- `plugins.entries.<id>.hooks.allowConversationAccess`: when `true`, trusted non-bundled plugins may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`.
- `plugins.entries.<id>.hooks.allowConversationAccess`: when `true`, trusted non-bundled plugins may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_model_resolve`, `before_agent_reply`, `before_agent_run`, `before_agent_finalize`, and `agent_end`.
- `plugins.entries.<id>.subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs.
- `plugins.entries.<id>.subagent.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted subagent overrides. Use `"*"` only when you intentionally want to allow any model.
- `plugins.entries.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available).

View File

@@ -104,6 +104,7 @@ observation-only.
- `agent_turn_prepare` - consume queued plugin turn injections and add same-turn context before prompt hooks
- `before_prompt_build` - add dynamic context or system-prompt text before the model call
- `before_agent_start` - compatibility-only combined phase; prefer the two hooks above
- **`before_agent_run`** - inspect the final prompt and session messages before model submission and optionally block the run
- **`before_agent_reply`** - short-circuit the model turn with a synthetic reply or silence
- **`before_agent_finalize`** - inspect the natural final answer and request one more model pass
- `agent_end` - observe final messages, success state, and run duration
@@ -232,6 +233,22 @@ Use the phase-specific hooks for new plugins:
`before_agent_start` remains for compatibility. Prefer the explicit hooks above
so your plugin does not depend on a legacy combined phase.
`before_agent_run` runs after prompt construction and before any model input,
including prompt-local image loading and `llm_input` observation. It receives
the current user input as `prompt`, plus loaded session history in `messages`
and the active system prompt. Return `{ outcome: "block", reason, message? }`
to stop the run before the model can read the prompt. `reason` is internal;
`message` is the user-facing replacement. The only supported outcomes are
`pass` and `block`; unsupported decision shapes fail closed.
When a run is blocked, OpenClaw stores only the replacement text in
`message.content` plus non-sensitive block metadata such as the blocking plugin
id and timestamp. The original user text is not retained in transcript or future
context. Internal block reasons are treated as sensitive and excluded from
transcript, history, broadcast, log, and diagnostics payloads. Observability
should use sanitized fields such as blocker id, outcome, timestamp, or a safe
category.
`before_agent_start` and `agent_end` include `event.runId` when OpenClaw can
identify the active run. The same value is also available on `ctx.runId`.
Cron-driven runs also expose `ctx.jobId` (the originating cron job id) so
@@ -280,8 +297,9 @@ type BeforeAgentFinalizeRetry = {
equivalent finalize decisions, and `maxAttempts` caps how many extra passes the
host will allow before continuing with the natural final answer.
Non-bundled plugins that need `llm_input`, `llm_output`,
`before_agent_finalize`, or `agent_end` must set:
Non-bundled plugins that need raw conversation hooks (`before_model_resolve`,
`before_agent_reply`, `llm_input`, `llm_output`, `before_agent_finalize`,
`agent_end`, or `before_agent_run`) must set:
```json
{

View File

@@ -406,8 +406,9 @@ do not run in live chat traffic, check these first:
containers, PID 1 may only be a supervisor; restart or signal the child
`openclaw gateway run` process.
- Use `openclaw plugins inspect <id> --runtime --json` to confirm hook registrations and
diagnostics. Non-bundled conversation hooks such as `llm_input`,
`llm_output`, `before_agent_finalize`, and `agent_end` need
diagnostics. Non-bundled conversation hooks such as `before_model_resolve`,
`before_agent_reply`, `before_agent_run`, `llm_input`, `llm_output`,
`before_agent_finalize`, and `agent_end` need
`plugins.entries.<id>.hooks.allowConversationAccess=true`.
- For model switching, prefer `before_model_resolve`. It runs before model
resolution for agent turns; `llm_output` only runs after a model attempt

View File

@@ -646,6 +646,34 @@ describe("diagnostics-otel service", () => {
await service.stop?.(ctx);
});
test("records hook-blocked run metrics with safe blocker originator", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitDiagnosticEvent({
type: "run.completed",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
outcome: "blocked",
blockedBy: "policy-plugin",
durationMs: 100,
});
await flushDiagnosticEvents();
expect(telemetryState.histograms.get("openclaw.run.duration_ms")?.record).toHaveBeenCalledWith(
100,
expect.objectContaining({
"openclaw.outcome": "blocked",
"openclaw.blocked_by": "policy-plugin",
}),
);
expect(JSON.stringify(telemetryState)).not.toContain("matched secret prompt");
await service.stop?.(ctx);
});
test("honors disabled traces when an OpenTelemetry SDK is preloaded", async () => {
process.env.OPENCLAW_OTEL_PRELOADED = "1";
const service = createDiagnosticsOtelService();

View File

@@ -1665,6 +1665,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
if (evt.channel) {
attrs["openclaw.channel"] = evt.channel;
}
if (evt.blockedBy) {
attrs["openclaw.blocked_by"] = lowCardinalityAttr(evt.blockedBy, "unknown");
}
durationHistogram.record(evt.durationMs, attrs);
if (!tracesEnabled) {
return;
@@ -1673,6 +1676,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
"openclaw.outcome": evt.outcome,
};
addRunAttrs(spanAttrs, evt);
if (evt.blockedBy) {
spanAttrs["openclaw.blocked_by"] = lowCardinalityAttr(evt.blockedBy, "unknown");
}
if (evt.errorCategory) {
spanAttrs["openclaw.errorCategory"] = lowCardinalityAttr(evt.errorCategory, "other");
}

View File

@@ -43,6 +43,37 @@ describe("diagnostics-prometheus service", () => {
expect(rendered).not.toContain("session-should-not-export");
});
it("records hook-blocked run metrics with safe blocker originator only", () => {
const store = __test__.createPrometheusMetricStore();
__test__.recordDiagnosticEvent(
store,
{
...baseEvent(),
type: "run.completed",
runId: "run-should-not-export",
sessionKey: "session-should-not-export",
provider: "openai",
model: "gpt-5.4",
channel: "slack",
trigger: "message",
durationMs: 250,
outcome: "blocked",
blockedBy: "policy-plugin",
},
trusted,
);
const rendered = __test__.renderPrometheusMetrics(store);
expect(rendered).toContain(
'openclaw_run_completed_total{blocked_by="policy-plugin",channel="slack",model="gpt-5.4",outcome="blocked",provider="openai",trigger="message"} 1',
);
expect(rendered).not.toContain("run-should-not-export");
expect(rendered).not.toContain("session-should-not-export");
expect(rendered).not.toContain("matched secret prompt");
});
it("drops untrusted plugin-emitted diagnostic events", () => {
const store = __test__.createPrometheusMetricStore();

View File

@@ -276,6 +276,7 @@ function renderPrometheusMetrics(store: PrometheusMetricStore): string {
}
function runLabels(evt: {
blockedBy?: string;
channel?: string;
model?: string;
outcome?: string;
@@ -283,6 +284,7 @@ function runLabels(evt: {
trigger?: string;
}): LabelSet {
return {
...(evt.blockedBy ? { blocked_by: lowCardinalityLabel(evt.blockedBy) } : {}),
channel: lowCardinalityLabel(evt.channel),
model: lowCardinalityLabel(evt.model),
outcome: lowCardinalityLabel(evt.outcome, "unknown"),

View File

@@ -58,7 +58,19 @@ function createSessionFile(params?: { history?: Array<{ role: "user"; content: s
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-hooks-"));
vi.stubEnv("OPENCLAW_STATE_DIR", dir);
const sessionFile = path.join(dir, "agents", "main", "sessions", "s1.jsonl");
const storePath = path.join(path.dirname(sessionFile), "sessions.json");
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
fs.writeFileSync(
storePath,
JSON.stringify({
"agent:main:main": {
sessionId: "s1",
sessionFile,
updatedAt: Date.now(),
},
}),
"utf-8",
);
fs.writeFileSync(
sessionFile,
`${JSON.stringify({
@@ -87,7 +99,7 @@ function createSessionFile(params?: { history?: Array<{ role: "user"; content: s
"utf-8",
);
}
return { dir, sessionFile };
return { dir, sessionFile, storePath };
}
function buildPreparedContext(params?: {
@@ -620,6 +632,100 @@ describe("runCliAgent reliability", () => {
}
});
it("blocks CLI runs before llm_input and model execution when before_agent_run blocks", async () => {
supervisorSpawnMock.mockClear();
const hookRunner = {
hasHooks: vi.fn((hookName: string) =>
["before_agent_run", "llm_input", "agent_end"].includes(hookName),
),
runBeforeAgentRun: vi.fn(async () => ({
pluginId: "policy-plugin",
decision: {
outcome: "block" as const,
reason: "matched secret prompt: secret prompt",
message: "The agent cannot read this message.",
},
})),
runLlmInput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
setHookRunnerForTest(hookRunner);
const { dir, sessionFile } = createSessionFile({
history: [{ role: "user", content: "earlier context" }],
});
try {
const result = await runPreparedCliAgent({
...buildPreparedContext({ sessionKey: "agent:main:main", runId: "run-blocked-cli" }),
params: {
...buildPreparedContext({ sessionKey: "agent:main:main", runId: "run-blocked-cli" })
.params,
agentId: "main",
sessionFile,
workspaceDir: dir,
prompt: "secret prompt",
},
});
expect(result.payloads).toEqual([
{
text: "Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
isError: true,
},
]);
expect(result.meta.livenessState).toBe("blocked");
expect(supervisorSpawnMock).not.toHaveBeenCalled();
expect(hookRunner.runLlmInput).not.toHaveBeenCalled();
expect(hookRunner.runBeforeAgentRun).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "secret prompt",
messages: expect.arrayContaining([
expect.objectContaining({ role: "user", content: "earlier context" }),
]),
}),
expect.objectContaining({
runId: "run-blocked-cli",
agentId: "main",
sessionKey: "agent:main:main",
}),
);
await vi.waitFor(() => {
expect(hookRunner.runAgentEnd).toHaveBeenCalledTimes(1);
});
expect(hookRunner.runAgentEnd).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error:
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
messages: expect.arrayContaining([
expect.objectContaining({
role: "user",
content:
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
}),
]),
}),
expect.any(Object),
);
expect(JSON.stringify(hookRunner.runAgentEnd.mock.calls)).not.toContain("secret prompt");
const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n");
const blockedLine = JSON.parse(lines[lines.length - 1]);
expect(blockedLine.message.content[0].text).toBe(
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
);
expect(JSON.stringify(blockedLine)).not.toContain("secret prompt");
expect(JSON.stringify(blockedLine)).not.toContain("matched secret prompt");
expect(blockedLine.message.__openclaw.beforeAgentRunBlocked).toMatchObject({
blockedBy: "policy-plugin",
});
expect(blockedLine.message.__openclaw.beforeAgentRunBlocked).not.toHaveProperty("reason");
expect(Object.hasOwn(blockedLine.message.__openclaw, "beforeAgentRunBlocked")).toBe(true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("does not emit llm_output when the CLI run returns no assistant text", async () => {
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "llm_output"),

View File

@@ -1,11 +1,15 @@
import { SessionManager } from "@mariozechner/pi-coding-agent";
import type { ReplyPayload } from "../auto-reply/reply-payload.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { buildAgentHookContextChannelFields } from "../plugins/hook-agent-context.js";
import { resolveBlockMessage } from "../plugins/hook-decision-types.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { loadCliSessionHistoryMessages } from "./cli-runner/session-history.js";
import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/types.js";
import { FailoverError, isFailoverError, resolveFailoverStatus } from "./failover-error.js";
import { buildAgentHookContext } from "./harness/hook-context.js";
import { buildAgentHookConversationMessages } from "./harness/hook-history.js";
import {
runAgentHarnessAgentEndHook,
@@ -15,6 +19,12 @@ import {
import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
const log = createSubsystemLogger("agents/cli-runner");
function flushSessionManagerFile(sessionManager: SessionManager): void {
(sessionManager as unknown as { _rewriteFile?: () => void })._rewriteFile?.();
}
function buildHandledReplyPayloads(reply?: ReplyPayload) {
const normalized = reply ?? { text: SILENT_REPLY_TOKEN };
return [
@@ -127,8 +137,9 @@ export async function runPreparedCliAgent(
const hasLlmInputHooks = hookRunner?.hasHooks("llm_input") === true;
const hasLlmOutputHooks = hookRunner?.hasHooks("llm_output") === true;
const hasAgentEndHooks = hookRunner?.hasHooks("agent_end") === true;
const hasBeforeAgentRunHooks = hookRunner?.hasHooks("before_agent_run") === true;
const historyMessages =
hasLlmInputHooks || hasAgentEndHooks
hasLlmInputHooks || hasAgentEndHooks || hasBeforeAgentRunHooks
? await loadCliSessionHistoryMessages({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
@@ -175,6 +186,88 @@ export async function runPreparedCliAgent(
durationMs: Date.now() - context.started,
});
const buildBlockedAgentEndEvent = (message: string) => ({
messages: buildAgentHookConversationMessages({
historyMessages,
currentTurnMessages: [buildCliHookUserMessage(message)],
}),
success: false,
error: message,
durationMs: Date.now() - context.started,
});
const buildBlockedBeforeAgentRunResult = (message: string): EmbeddedPiRunResult => ({
payloads: [{ text: message, isError: true }],
meta: {
durationMs: Date.now() - context.started,
finalAssistantVisibleText: message,
finalAssistantRawText: message,
livenessState: "blocked",
error: {
kind: "hook_block",
message,
},
systemPromptReport: context.systemPromptReport,
executionTrace: {
winnerProvider: params.provider,
winnerModel: context.modelId,
attempts: [
{
provider: params.provider,
model: context.modelId,
result: "error",
reason: "before_agent_run blocked the run",
},
],
fallbackUsed: false,
runner: "cli",
},
requestShaping: {
...(params.thinkLevel ? { thinking: params.thinkLevel } : {}),
...(context.effectiveAuthProfileId ? { authMode: "auth-profile" } : {}),
},
completion: {
finishReason: "blocked",
stopReason: "blocked",
refusal: true,
},
agentMeta: {
sessionId: params.sessionId ?? "",
provider: params.provider,
model: context.modelId,
},
},
});
const persistBlockedBeforeAgentRun = async (block: {
message: string;
pluginId: string;
}): Promise<void> => {
try {
const nowMs = Date.now();
const sessionManager = SessionManager.open(params.sessionFile);
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: block.message }],
timestamp: nowMs,
idempotencyKey: `hook-block:before_agent_run:user:${params.runId}`,
__openclaw: {
beforeAgentRunBlocked: {
blockedBy: block.pluginId,
blockedAt: nowMs,
},
},
} as Parameters<typeof sessionManager.appendMessage>[0]);
flushSessionManagerFile(sessionManager);
} catch (err) {
log.warn(
`before_agent_run block: failed to persist redacted CLI user message: ${formatErrorMessage(
err,
)}`,
);
}
};
const toCliRunFailure = (error: unknown): never => {
if (isFailoverError(error)) {
throw error;
@@ -304,6 +397,60 @@ export async function runPreparedCliAgent(
// Try with the provided CLI session ID first
try {
if (hasBeforeAgentRunHooks && hookRunner) {
let beforeRunResult:
| Awaited<ReturnType<NonNullable<typeof hookRunner>["runBeforeAgentRun"]>>
| undefined;
try {
beforeRunResult = await hookRunner.runBeforeAgentRun(
{
prompt: params.prompt,
systemPrompt: context.systemPrompt,
messages: buildAgentHookConversationMessages({
historyMessages,
currentTurnMessages: [],
}),
channelId: hookContext.channelId,
accountId: params.agentAccountId,
senderIsOwner: params.senderIsOwner,
},
buildAgentHookContext(hookContext),
);
} catch {
const blockMessage = resolveBlockMessage(
{ outcome: "block", reason: "before_agent_run hook failed" },
{ blockedBy: "before_agent_run" },
);
await persistBlockedBeforeAgentRun({
message: blockMessage,
pluginId: "before_agent_run",
});
runAgentHarnessAgentEndHook({
event: buildBlockedAgentEndEvent(blockMessage),
ctx: hookContext,
hookRunner,
});
return buildBlockedBeforeAgentRunResult(blockMessage);
}
const beforeRunDecision = beforeRunResult?.decision;
if (beforeRunDecision?.outcome === "block") {
const blockMessage = resolveBlockMessage(beforeRunDecision, {
blockedBy: beforeRunResult?.pluginId ?? "unknown",
});
await persistBlockedBeforeAgentRun({
message: blockMessage,
pluginId: beforeRunResult?.pluginId ?? "unknown",
});
runAgentHarnessAgentEndHook({
event: buildBlockedAgentEndEvent(blockMessage),
ctx: hookContext,
hookRunner,
});
return buildBlockedBeforeAgentRunResult(blockMessage);
}
}
runAgentHarnessLlmInputHook({
event: llmInputEvent,
ctx: hookContext,

View File

@@ -698,6 +698,28 @@ describe("runWithModelFallback", () => {
).toBeNull();
});
it("keeps before_agent_run hook blocks out of empty-result fallback", () => {
const runResult: EmbeddedPiRunResult = {
payloads: [{ text: "Blocked by before-run policy.", isError: true }],
meta: {
durationMs: 1,
livenessState: "blocked",
error: {
kind: "hook_block",
message: "Blocked by before-run policy.",
},
},
};
expect(
classifyEmbeddedPiRunResultForModelFallback({
provider: "atlassian-ai-gateway-openai",
model: "gpt-5.5-2026-04-23",
result: runResult,
}),
).toBeNull();
});
it("uses harness-owned terminal classification for GPT-5 fallback", () => {
const runResult: EmbeddedPiRunResult = {
payloads: [],

View File

@@ -18,6 +18,9 @@ function isEmbeddedPiRunResult(value: unknown): value is EmbeddedPiRunResult {
}
function hasDeliberateSilentTerminalReply(result: EmbeddedPiRunResult): boolean {
if (result.meta.error?.kind === "hook_block") {
return true;
}
return [result.meta.finalAssistantRawText, result.meta.finalAssistantVisibleText].some(
(text) => typeof text === "string" && isSilentReplyPayloadText(text),
);

View File

@@ -49,6 +49,36 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
mockedGlobalHookRunner.hasHooks.mockImplementation(() => false);
});
it("emits the before_agent_run hook block message as the agent payload", async () => {
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
assistantTexts: [],
promptError: new Error("Blocked by before-run policy."),
promptErrorSource: "hook:before_agent_run",
}),
);
const result = await runEmbeddedPiAgent({
...overflowBaseRunParams,
runId: "run-before-agent-run-hook-block",
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
expect(result.payloads).toEqual([{ text: "Blocked by before-run policy.", isError: true }]);
expect(result.meta).toMatchObject({
finalAssistantVisibleText: "Blocked by before-run policy.",
finalAssistantRawText: "Blocked by before-run policy.",
finalPromptText: undefined,
livenessState: "blocked",
error: { kind: "hook_block", message: "Blocked by before-run policy." },
});
expect(result.meta?.error).toEqual({
kind: "hook_block",
message: "Blocked by before-run policy.",
});
expect(result.meta?.livenessState).toBe("blocked");
});
it("warns before retrying when an incomplete turn already sent a message", async () => {
mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockResolvedValueOnce(

View File

@@ -1826,6 +1826,38 @@ export async function runEmbeddedPiAgent(
};
}
if (promptErrorSource === "hook:before_agent_run" && !aborted) {
const errorText = formatErrorMessage(promptError);
const replayInvalid = resolveReplayInvalidForAttempt();
attempt.setTerminalLifecycleMeta?.({
replayInvalid,
livenessState: "blocked",
});
return {
payloads: [{ text: errorText, isError: true }],
meta: {
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: sessionIdUsed,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,
usageAccumulator,
lastRunPromptUsage,
lastAssistant: sessionLastAssistant,
lastTurnTotal,
}),
systemPromptReport: attempt.systemPromptReport,
finalAssistantVisibleText: errorText,
finalAssistantRawText: errorText,
finalPromptText: undefined,
replayInvalid,
livenessState: "blocked",
error: { kind: "hook_block", message: errorText },
},
};
}
if (promptError && !aborted && promptErrorSource !== "compaction") {
// Normalize wrapped errors (e.g. abort-wrapped RESOURCE_EXHAUSTED) into
// FailoverError so rate-limit classification works even for nested shapes.

View File

@@ -20,6 +20,7 @@ import {
resolveAttemptFsWorkspaceOnly,
resolveEmbeddedAgentStreamFn,
resolveUnknownToolGuardThreshold,
shouldRunLlmOutputHooksForAttempt,
resolveAttemptToolPolicyMessageProvider,
resolvePromptBuildHookResult,
resolvePromptModeForSession,
@@ -149,6 +150,43 @@ describe("normalizeMessagesForLlmBoundary", () => {
expect.arrayContaining([expect.objectContaining({ customType: "other-extension-context" })]),
);
});
it("keeps only safe blocked metadata at the LLM boundary", () => {
const input = [
{
role: "user",
content: [
{
type: "text",
text: "Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
},
],
timestamp: 1,
__openclaw: {
beforeAgentRunBlocked: {
blockedBy: "policy-plugin",
blockedAt: 1,
},
},
},
];
const output = normalizeMessagesForLlmBoundary(
input as Parameters<typeof normalizeMessagesForLlmBoundary>[0],
) as Array<Record<string, unknown>>;
expect(output[0]?.content).toEqual([
{
type: "text",
text: "Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
},
]);
expect(output[0]).toHaveProperty("__openclaw.beforeAgentRunBlocked");
expect(output[0]).not.toHaveProperty("__openclaw.beforeAgentRunBlocked.reason");
expect(JSON.stringify(output)).not.toContain("secret prompt");
expect(JSON.stringify(output)).not.toContain("matched secret prompt");
expect(input[0]).toHaveProperty("__openclaw");
});
});
describe("resolveAttemptToolPolicyMessageProvider", () => {
@@ -166,6 +204,16 @@ describe("resolveAttemptToolPolicyMessageProvider", () => {
});
});
describe("shouldRunLlmOutputHooksForAttempt", () => {
it("skips llm_output after before_agent_run blocks before model submission", () => {
expect(shouldRunLlmOutputHooksForAttempt({ promptErrorSource: "hook:before_agent_run" })).toBe(
false,
);
expect(shouldRunLlmOutputHooksForAttempt({ promptErrorSource: "prompt" })).toBe(true);
expect(shouldRunLlmOutputHooksForAttempt({ promptErrorSource: null })).toBe(true);
});
});
describe("resolvePromptBuildHookResult", () => {
function createLegacyOnlyHookRunner() {
return {

View File

@@ -25,6 +25,7 @@ import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
import { listRegisteredPluginAgentPromptGuidance } from "../../../plugins/command-registry-state.js";
import { getCurrentPluginMetadataSnapshot } from "../../../plugins/current-plugin-metadata-snapshot.js";
import { buildAgentHookContextChannelFields } from "../../../plugins/hook-agent-context.js";
import { resolveBlockMessage } from "../../../plugins/hook-decision-types.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import {
extractModelCompat,
@@ -335,6 +336,7 @@ import {
} from "./preemptive-compaction.js";
import {
buildCurrentTurnPromptContextSuffix,
buildRuntimeContextSystemContext,
queueRuntimeContextForNextTurn,
resolveRuntimeContextPromptParts,
} from "./runtime-context-prompt.js";
@@ -493,6 +495,29 @@ export function normalizeMessagesForLlmBoundary(messages: AgentMessage[]): Agent
return stripHistoricalRuntimeContextCustomMessages(normalized);
}
function cloneHookMessages(messages: AgentMessage[]): AgentMessage[] {
return messages.map((message) => structuredClone(message));
}
function sessionMessagesContainIdempotencyKey(
messages: AgentMessage[],
idempotencyKey: string,
): boolean {
return messages.some(
(message) =>
typeof (message as { idempotencyKey?: unknown }).idempotencyKey === "string" &&
(message as { idempotencyKey?: unknown }).idempotencyKey === idempotencyKey,
);
}
function flushSessionManagerFile(sessionManager: ReturnType<typeof guardSessionManager>): void {
(sessionManager as unknown as { _rewriteFile?: () => void })._rewriteFile?.();
}
export function shouldRunLlmOutputHooksForAttempt(params: { promptErrorSource: string | null }) {
return params.promptErrorSource !== "hook:before_agent_run";
}
function isMidTurnPrecheckAssistantError(message: AgentMessage | undefined): boolean {
if (!message || message.role !== "assistant") {
return false;
@@ -711,8 +736,14 @@ export async function runEmbeddedAttempt(
let timedOutDuringToolExecution = false;
let promptError: unknown = null;
let emitDiagnosticRunCompleted:
| ((outcome: "completed" | "aborted" | "error", err?: unknown) => void)
| ((
outcome: "completed" | "aborted" | "blocked" | "error",
err?: unknown,
extra?: { blockedBy?: string },
) => void)
| undefined;
let beforeAgentRunBlocked = false;
let beforeAgentRunBlockedBy: string | undefined;
try {
const skillsSnapshotForRun =
sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? undefined : params.skillsSnapshot;
@@ -775,7 +806,7 @@ export async function runEmbeddedAttempt(
});
const diagnosticRunStartedAt = Date.now();
let diagnosticRunCompleted = false;
emitDiagnosticRunCompleted = (outcome, err) => {
emitDiagnosticRunCompleted = (outcome, err, extra) => {
if (diagnosticRunCompleted) {
return;
}
@@ -785,7 +816,8 @@ export async function runEmbeddedAttempt(
...diagnosticRunBase,
durationMs: Date.now() - diagnosticRunStartedAt,
outcome,
...(err ? { errorCategory: diagnosticErrorCategory(err) } : {}),
...(extra?.blockedBy ? { blockedBy: extra.blockedBy } : {}),
...(err && outcome !== "blocked" ? { errorCategory: diagnosticErrorCategory(err) } : {}),
});
};
const corePluginToolStages = createEmbeddedRunStageTracker();
@@ -2496,7 +2528,7 @@ export async function runEmbeddedAttempt(
const activeSessionManager = sessionManager;
let preflightRecovery: EmbeddedRunAttemptResult["preflightRecovery"];
let promptErrorSource: "prompt" | "compaction" | "precheck" | null = null;
let promptErrorSource: EmbeddedRunAttemptResult["promptErrorSource"] = null;
const handleMidTurnPrecheckRequest = (request: MidTurnPrecheckRequest) => {
const logMidTurnPrecheck = (route: string, extra?: string) => {
log.warn(
@@ -2659,25 +2691,6 @@ export async function runEmbeddedAttempt(
});
}
const googlePromptCacheStreamFn = await prepareGooglePromptCacheStreamFn({
apiKey: await resolveEmbeddedAgentApiKey({
provider: params.provider,
resolvedApiKey: params.resolvedApiKey,
authStorage: params.authStorage,
}),
extraParams: effectiveExtraParams,
model: params.model,
modelId: params.modelId,
provider: params.provider,
sessionManager,
signal: runAbortController.signal,
streamFn: activeSession.agent.streamFn,
systemPrompt: systemPromptText,
});
if (googlePromptCacheStreamFn) {
activeSession.agent.streamFn = googlePromptCacheStreamFn;
}
const routingSummary = describeProviderRequestRoutingSummary({
provider: params.provider,
api: params.model.api,
@@ -2689,11 +2702,6 @@ export async function runEmbeddedAttempt(
`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId} ` +
routingSummary,
);
cacheTrace?.recordStage("prompt:before", {
prompt: effectivePrompt,
messages: activeSession.messages,
});
// Repair orphaned trailing user messages so new prompts don't violate role ordering.
const leafEntry = isRawModelRun ? null : sessionManager.getLeafEntry();
if (leafEntry?.type === "message" && leafEntry.message.role === "user") {
@@ -2776,40 +2784,182 @@ export async function runEmbeddedAttempt(
systemPromptText = runtimeSystemPrompt;
}
}
const runtimeContextForHook = promptSubmission.runtimeOnly
? undefined
: promptSubmission.runtimeContext?.trim();
const runtimeSystemPromptForHook = runtimeContextForHook
? composeSystemPromptWithHookContext({
baseSystemPrompt: systemPromptText,
appendSystemContext: buildRuntimeContextSystemContext(runtimeContextForHook),
})
: undefined;
const systemPromptForHook = runtimeSystemPromptForHook ?? systemPromptText;
const persistBlockedBeforeAgentRun = async (block: {
message: string;
pluginId: string;
}): Promise<boolean> => {
const idempotencyKey = `hook-block:before_agent_run:user:${params.runId}`;
if (sessionMessagesContainIdempotencyKey(activeSession.messages, idempotencyKey)) {
return true;
}
const nowMs = Date.now();
const redactedUserMessage = {
role: "user" as const,
content: [{ type: "text" as const, text: block.message }],
timestamp: nowMs,
idempotencyKey,
__openclaw: {
beforeAgentRunBlocked: {
blockedBy: block.pluginId,
blockedAt: nowMs,
},
},
};
try {
activeSessionManager.appendMessage(
redactedUserMessage as Parameters<typeof activeSessionManager.appendMessage>[0],
);
flushSessionManagerFile(activeSessionManager);
activeSession.agent.state.messages =
activeSessionManager.buildSessionContext().messages;
return true;
} catch (err) {
log.warn(
`before_agent_run block: failed to persist redacted user message: ${
(err as Error)?.message ?? String(err)
}`,
);
return false;
}
};
if (hookRunner?.hasHooks("before_agent_run")) {
const beforeRunMessages = cloneHookMessages(
normalizeMessagesForLlmBoundary(activeSession.messages),
);
let beforeRunResult:
| Awaited<ReturnType<NonNullable<typeof hookRunner>["runBeforeAgentRun"]>>
| undefined;
try {
beforeRunResult = await hookRunner.runBeforeAgentRun(
{
prompt: promptForModel,
systemPrompt: systemPromptForHook,
messages: beforeRunMessages,
channelId: hookCtx.channelId,
accountId: params.agentAccountId ?? undefined,
senderId: params.senderId ?? undefined,
senderIsOwner: params.senderIsOwner ?? undefined,
},
hookCtx,
);
} catch {
log.warn("before_agent_run hook failed; blocking request");
beforeAgentRunBlocked = true;
beforeAgentRunBlockedBy = "before_agent_run";
await persistBlockedBeforeAgentRun({
message: resolveBlockMessage(
{ outcome: "block", reason: "before_agent_run hook failed" },
{ blockedBy: "before_agent_run" },
),
pluginId: "before_agent_run",
});
promptError = new Error(
resolveBlockMessage(
{ outcome: "block", reason: "before_agent_run hook failed" },
{ blockedBy: "before_agent_run" },
),
);
promptErrorSource = "hook:before_agent_run";
skipPromptSubmission = true;
}
const beforeRunDecision = beforeRunResult?.decision;
const beforeRunPluginId = beforeRunResult?.pluginId ?? "unknown";
if (beforeRunDecision?.outcome === "block") {
beforeAgentRunBlocked = true;
beforeAgentRunBlockedBy = beforeRunPluginId;
const blockReplacementMsg = resolveBlockMessage(beforeRunDecision, {
blockedBy: beforeRunPluginId,
});
log.warn(`before_agent_run hook blocked by ${beforeRunPluginId}`);
await persistBlockedBeforeAgentRun({
message: blockReplacementMsg,
pluginId: beforeRunPluginId,
});
promptError = new Error(blockReplacementMsg);
promptErrorSource = "hook:before_agent_run";
skipPromptSubmission = true;
}
}
if (!skipPromptSubmission) {
const googlePromptCacheStreamFn = await prepareGooglePromptCacheStreamFn({
apiKey: await resolveEmbeddedAgentApiKey({
provider: params.provider,
resolvedApiKey: params.resolvedApiKey,
authStorage: params.authStorage,
}),
extraParams: effectiveExtraParams,
model: params.model,
modelId: params.modelId,
provider: params.provider,
sessionManager,
signal: runAbortController.signal,
streamFn: activeSession.agent.streamFn,
systemPrompt: systemPromptText,
});
if (googlePromptCacheStreamFn) {
activeSession.agent.streamFn = googlePromptCacheStreamFn;
}
}
// Detect and load images referenced in the visible prompt for vision-capable models.
// Images are prompt-local only (pi-like behavior).
const imageResult = await detectAndLoadPromptImages({
prompt: promptSubmission.prompt,
workspaceDir: effectiveWorkspace,
model: params.model,
existingImages: params.images,
imageOrder: params.imageOrder,
maxBytes: MAX_IMAGE_BYTES,
maxDimensionPx: resolveImageSanitizationLimits(params.config).maxDimensionPx,
workspaceOnly: effectiveFsWorkspaceOnly,
// Enforce sandbox path restrictions when sandbox is enabled
sandbox:
sandbox?.enabled && sandbox?.fsBridge
? { root: sandbox.workspaceDir, bridge: sandbox.fsBridge }
: undefined,
});
const imageResult = skipPromptSubmission
? {
images: [],
detectedRefs: [],
loadedCount: 0,
skippedCount: 0,
}
: await detectAndLoadPromptImages({
prompt: promptSubmission.prompt,
workspaceDir: effectiveWorkspace,
model: params.model,
existingImages: params.images,
imageOrder: params.imageOrder,
maxBytes: MAX_IMAGE_BYTES,
maxDimensionPx: resolveImageSanitizationLimits(params.config).maxDimensionPx,
workspaceOnly: effectiveFsWorkspaceOnly,
// Enforce sandbox path restrictions when sandbox is enabled
sandbox:
sandbox?.enabled && sandbox?.fsBridge
? { root: sandbox.workspaceDir, bridge: sandbox.fsBridge }
: undefined,
});
cacheTrace?.recordStage("prompt:images", {
prompt: promptForModel,
messages: activeSession.messages,
note: `images: prompt=${imageResult.images.length}`,
});
trajectoryRecorder?.recordEvent("context.compiled", {
systemPrompt: systemPromptText,
prompt: promptForModel,
messages: activeSession.messages,
tools: toTrajectoryToolDefinitions(effectiveTools),
imagesCount: imageResult.images.length,
streamStrategy,
transport: effectiveAgentTransport,
transcriptLeafId,
});
if (!skipPromptSubmission) {
cacheTrace?.recordStage("prompt:before", {
prompt: promptForModel,
messages: activeSession.messages,
});
cacheTrace?.recordStage("prompt:images", {
prompt: promptForModel,
messages: activeSession.messages,
note: `images: prompt=${imageResult.images.length}`,
});
trajectoryRecorder?.recordEvent("context.compiled", {
systemPrompt: systemPromptForHook,
prompt: promptForModel,
messages: activeSession.messages,
tools: toTrajectoryToolDefinitions(effectiveTools),
imagesCount: imageResult.images.length,
streamStrategy,
transport: effectiveAgentTransport,
transcriptLeafId,
});
}
const promptSkipReason = skipPromptSubmission
? null
@@ -2880,7 +3030,7 @@ export async function runEmbeddedAttempt(
);
}
if (!isRawModelRun && hookRunner?.hasHooks("llm_input")) {
if (!skipPromptSubmission && !isRawModelRun && hookRunner?.hasHooks("llm_input")) {
hookRunner
.runLlmInput(
{
@@ -2888,9 +3038,11 @@ export async function runEmbeddedAttempt(
sessionId: params.sessionId,
provider: params.provider,
model: params.modelId,
systemPrompt: systemPromptText,
prompt: effectivePrompt,
historyMessages: activeSession.messages,
systemPrompt: systemPromptForHook,
prompt: promptForModel,
historyMessages: cloneHookMessages(
normalizeMessagesForLlmBoundary(activeSession.messages),
),
imagesCount: imageResult.images.length,
},
{
@@ -2909,22 +3061,24 @@ export async function runEmbeddedAttempt(
});
}
const preemptiveCompaction = shouldPreemptivelyCompactBeforePrompt({
messages: activeSession.messages,
...(contextEnginePromptAuthority === "preassembly_may_overflow"
? { unwindowedMessages: unwindowedContextEngineMessagesForPrecheck }
: {}),
systemPrompt: systemPromptText,
prompt: effectivePrompt,
contextTokenBudget,
reserveTokens,
toolResultMaxChars: resolveLiveToolResultMaxChars({
contextWindowTokens: contextTokenBudget,
cfg: params.config,
agentId: sessionAgentId,
}),
});
if (preemptiveCompaction.route === "truncate_tool_results_only") {
const preemptiveCompaction = skipPromptSubmission
? null
: shouldPreemptivelyCompactBeforePrompt({
messages: activeSession.messages,
...(contextEnginePromptAuthority === "preassembly_may_overflow"
? { unwindowedMessages: unwindowedContextEngineMessagesForPrecheck }
: {}),
systemPrompt: systemPromptForHook,
prompt: promptForModel,
contextTokenBudget,
reserveTokens,
toolResultMaxChars: resolveLiveToolResultMaxChars({
contextWindowTokens: contextTokenBudget,
cfg: params.config,
agentId: sessionAgentId,
}),
});
if (preemptiveCompaction?.route === "truncate_tool_results_only") {
const toolResultMaxChars = resolveLiveToolResultMaxChars({
contextWindowTokens: contextTokenBudget,
cfg: params.config,
@@ -2969,7 +3123,7 @@ export async function runEmbeddedAttempt(
skipPromptSubmission = true;
}
}
if (preemptiveCompaction.shouldCompact) {
if (preemptiveCompaction?.shouldCompact) {
preflightRecovery =
preemptiveCompaction.route === "compact_then_truncate"
? { route: "compact_then_truncate" }
@@ -3001,7 +3155,7 @@ export async function runEmbeddedAttempt(
finalPromptText = promptForModel;
trajectoryRecorder?.recordEvent("prompt.submitted", {
prompt: promptForModel,
systemPrompt: systemPromptText,
systemPrompt: systemPromptForHook,
messages: activeSession.messages,
imagesCount: imageResult.images.length,
});
@@ -3014,10 +3168,9 @@ export async function runEmbeddedAttempt(
if (promptSubmission.runtimeOnly) {
await abortable(activeSession.prompt(promptForModel));
} else {
const runtimeContext = promptSubmission.runtimeContext?.trim();
await queueRuntimeContextForNextTurn({
session: activeSession,
runtimeContext,
runtimeContext: runtimeContextForHook,
});
// Only pass images option if there are actually images to pass
@@ -3435,7 +3588,10 @@ export async function runEmbeddedAttempt(
}
}
if (hookRunner?.hasHooks("llm_output")) {
if (
hookRunner?.hasHooks("llm_output") &&
shouldRunLlmOutputHooksForAttempt({ promptErrorSource })
) {
hookRunner
.runLlmOutput(
{
@@ -3642,12 +3798,19 @@ export async function runEmbeddedAttempt(
cleanupError = err;
}
emitDiagnosticRunCompleted?.(
cleanupError || promptError
cleanupError
? "error"
: aborted || timedOut || idleTimedOut || timedOutDuringCompaction
? "aborted"
: "completed",
: beforeAgentRunBlocked
? "blocked"
: promptError
? "error"
: aborted || timedOut || idleTimedOut || timedOutDuringCompaction
? "aborted"
: "completed",
cleanupError ?? promptError,
beforeAgentRunBlocked
? { blockedBy: beforeAgentRunBlockedBy ?? "before_agent_run" }
: undefined,
);
if (cleanupError) {
await Promise.reject(cleanupError);

View File

@@ -71,9 +71,10 @@ export type EmbeddedRunAttemptResult = {
* this must not be retried as a fresh prompt or the same tool turn can replay.
* - "precheck": pre-prompt overflow recovery intentionally short-circuited the prompt so the
* outer run loop can recover via compaction/truncation before any model call is made.
* - "hook:before_agent_run": a lifecycle hook blocked the run before the prompt was sent.
* - null: no promptError.
*/
promptErrorSource: "prompt" | "compaction" | "precheck" | null;
promptErrorSource: "prompt" | "compaction" | "precheck" | "hook:before_agent_run" | null;
preflightRecovery?:
| {
route: Exclude<PreemptiveCompactionRoute, "fits">;

View File

@@ -140,7 +140,8 @@ export type EmbeddedPiRunMeta = {
| "compaction_failure"
| "role_ordering"
| "image_size"
| "retry_limit";
| "retry_limit"
| "hook_block";
message: string;
};
failureSignal?: EmbeddedRunFailureSignal;

View File

@@ -960,6 +960,33 @@ describe("before_tool_call requireApproval handling", () => {
expect(onResolution).toHaveBeenCalledWith("allow-once");
});
it("allows allow-always decisions for tool approvals", async () => {
const onResolution = vi.fn();
hookRunner.runBeforeToolCall.mockResolvedValue({
requireApproval: {
title: "Needs durable approval",
description: "Check this durable approval",
onResolution,
},
});
mockCallGateway.mockResolvedValueOnce({ id: "server-id-allow-always", status: "accepted" });
mockCallGateway.mockResolvedValueOnce({
id: "server-id-allow-always",
decision: "allow-always",
});
const result = await runBeforeToolCallHook({
toolName: "bash",
params: { command: "echo ok" },
ctx: { agentId: "main", sessionKey: "main" },
});
expect(result).toEqual({ blocked: false, params: { command: "echo ok" } });
expect(onResolution).toHaveBeenCalledWith("allow-always");
});
it("does not await onResolution before returning approval outcome", async () => {
const onResolution = vi.fn(() => new Promise<void>(() => {}));

View File

@@ -39,6 +39,16 @@ export type ToolOutcomeObservation = {
export type ToolOutcomeObserver = (observation: ToolOutcomeObservation) => void;
export function isAbortSignalCancellation(err: unknown, signal?: AbortSignal): boolean {
if (!signal?.aborted) {
return false;
}
if (err === signal.reason) {
return true;
}
return err instanceof Error && err.name === "AbortError";
}
export type HookContext = {
agentId?: string;
config?: OpenClawConfig;
@@ -47,6 +57,7 @@ export type HookContext = {
sessionId?: string;
runId?: string;
trace?: DiagnosticTraceContext;
channelId?: string;
loopDetection?: ToolLoopDetectionConfig;
onToolOutcome?: ToolOutcomeObserver;
};
@@ -114,19 +125,6 @@ function mergeParamsWithApprovalOverrides(
return originalParams;
}
function isAbortSignalCancellation(err: unknown, signal?: AbortSignal): boolean {
if (!signal?.aborted) {
return false;
}
if (err === signal.reason) {
return true;
}
if (err instanceof Error && err.name === "AbortError") {
return true;
}
return false;
}
function unwrapErrorCause(err: unknown): unknown {
try {
if (!(err instanceof Error)) {
@@ -180,6 +178,7 @@ async function requestPluginToolApproval(params: {
title: approval.title,
description: approval.description,
severity: approval.severity,
allowedDecisions: approval.allowedDecisions,
toolName: params.toolName,
toolCallId: params.toolCallId,
agentId: params.ctx?.agentId,
@@ -504,6 +503,7 @@ export async function runBeforeToolCallHook(args: {
...(args.ctx?.runId && { runId: args.ctx.runId }),
...(args.ctx?.trace && { trace: freezeDiagnosticTraceContext(args.ctx.trace) }),
...(args.toolCallId && { toolCallId: args.toolCallId }),
...(args.ctx?.channelId && { channelId: args.ctx.channelId }),
};
const trustedPolicyResult = await runTrustedToolPolicies(
{

View File

@@ -56,6 +56,7 @@ export type ReplyPayloadMetadata = {
* assistant source replies are message-tool-only; sendPolicy deny still wins.
*/
deliverDespiteSourceReplySuppression?: boolean;
beforeAgentRunBlocked?: boolean;
};
const replyPayloadMetadata = new WeakMap<object, ReplyPayloadMetadata>();

View File

@@ -1690,6 +1690,104 @@ describe("runReplyAgent claude-cli routing", () => {
expect(result).toMatchObject({ text: "ok" });
});
it("does not leak hook-blocked CLI input in raw trace payloads", async () => {
runCliAgentMock.mockResolvedValueOnce({
payloads: [
{
text: "Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
isError: true,
},
],
meta: {
error: {
kind: "hook_block",
message:
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
},
agentMeta: {
provider: "claude-cli",
model: "opus-4.5",
},
},
});
const typing = createMockTypingController();
const sessionCtx = {
Provider: "webchat",
OriginatingTo: "session:1",
AccountId: "primary",
MessageSid: "msg",
CommandBody: "secret hitl prompt",
RawBody: "secret hitl prompt",
BodyForAgent: "secret hitl prompt",
Body: "secret hitl prompt",
} as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
const sessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
traceLevel: "raw",
} as SessionEntry;
const followupRun = {
prompt: "secret hitl prompt",
summaryLine: "secret hitl prompt",
enqueuedAt: Date.now(),
run: {
agentId: "main",
sessionId: "session",
sessionKey: "main",
messageProvider: "webchat",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: createCliBackendTestConfig(),
skillsSnapshot: {},
traceAuthorized: true,
provider: "claude-cli",
model: "opus-4.5",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "message_end",
},
} as unknown as FollowupRun;
const result = await runReplyAgent({
commandBody: "secret hitl prompt",
followupRun,
queueKey: "main",
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing,
sessionCtx,
sessionEntry,
sessionStore: { main: sessionEntry },
defaultModel: "claude-cli/opus-4.5",
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
});
const texts = Array.isArray(result)
? result.map((payload) => payload.text ?? "").join("\n")
: (result?.text ?? "");
expect(texts).toContain(
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
);
expect(texts).not.toContain("secret hitl prompt");
});
it("uses the selected CLI runtime for canonical Anthropic models", async () => {
runCliAgentMock.mockResolvedValueOnce({
payloads: [{ text: "ok" }],

View File

@@ -38,7 +38,10 @@ import {
buildFallbackNotice,
resolveFallbackTransition,
} from "../fallback-state.js";
import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js";
import {
markReplyPayloadForSourceSuppressionDelivery,
setReplyPayloadMetadata,
} from "../reply-payload.js";
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
@@ -93,6 +96,12 @@ import type { TypingController } from "./typing.js";
const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
function markBeforeAgentRunBlockedPayloads(payloads: ReplyPayload[]): ReplyPayload[] {
return payloads.map((payload) =>
setReplyPayloadMetadata(payload, { beforeAgentRunBlocked: true }),
);
}
function buildInlinePluginStatusPayload(params: {
entry: SessionEntry | undefined;
includeTraceLines: boolean;
@@ -1699,14 +1708,17 @@ export async function runReplyAgent(params: {
}
}
const prefixPayloads = [...verboseNotices];
const rawUserText =
runResult.meta?.finalPromptText ??
sessionCtx.CommandBody ??
sessionCtx.RawBody ??
sessionCtx.BodyForAgent ??
sessionCtx.Body;
const rawAssistantText =
runResult.meta?.finalAssistantRawText ?? runResult.meta?.finalAssistantVisibleText;
const isHookBlockedRun = runResult.meta?.error?.kind === "hook_block";
const rawUserText = isHookBlockedRun
? runResult.meta?.finalPromptText
: (runResult.meta?.finalPromptText ??
sessionCtx.CommandBody ??
sessionCtx.RawBody ??
sessionCtx.BodyForAgent ??
sessionCtx.Body);
const rawAssistantText = isHookBlockedRun
? undefined
: (runResult.meta?.finalAssistantRawText ?? runResult.meta?.finalAssistantVisibleText);
const traceAuthorized = followupRun.run.traceAuthorized === true;
const executionTrace = mergeExecutionTrace({
fallbackAttempts,
@@ -1838,6 +1850,9 @@ export async function runReplyAgent(params: {
if (responseUsageLine) {
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
}
if (isHookBlockedRun) {
finalPayloads = markBeforeAgentRunBlockedPayloads(finalPayloads);
}
// Capture only policy-visible final payloads in session store to support
// durable delivery retries. Hidden reasoning, message-tool-only replies,

View File

@@ -4581,6 +4581,38 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
);
});
it("preserves hook-blocked metadata when source delivery is message-tool-only", async () => {
setNoAbort();
sessionStoreMocks.currentEntry = {
sessionId: "s1",
updatedAt: 0,
sendPolicy: "allow",
};
const dispatcher = createDispatcher();
const blockedReply = setReplyPayloadMetadata(
{ text: "Your message could not be sent: blocked by policy-plugin", isError: true },
{ beforeAgentRunBlocked: true },
);
const replyResolver = vi.fn(async () => blockedReply satisfies ReplyPayload);
const result = await dispatchReplyFromConfig({
ctx: buildTestCtx({ SessionKey: "test:session" }),
cfg: emptyConfig,
dispatcher,
replyResolver,
replyOptions: {
sourceReplyDeliveryMode: "message_tool_only",
},
});
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(false);
expect(result.beforeAgentRunBlocked).toBe(true);
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
});
it("delivers marked runtime failure notices in message-tool-only mode", async () => {
setNoAbort();
sessionStoreMocks.currentEntry = {

View File

@@ -95,7 +95,10 @@ import { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js";
import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js";
import { resolveOriginMessageProvider } from "./origin-routing.js";
import { resolveReplyRoutingDecision } from "./routing-policy.js";
import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js";
import {
isExplicitSourceReplyCommand,
resolveSourceReplyVisibilityPolicy,
} from "./source-reply-delivery-mode.js";
import { resolveRunTypingPolicy } from "./typing-policy.js";
const routeReplyRuntimeLoader = createLazyImportLoader(() => import("./route-reply.runtime.js"));
@@ -711,7 +714,7 @@ export async function dispatchReplyFromConfig(
const prefersMessageToolDelivery =
params.replyOptions?.sourceReplyDeliveryMode === "message_tool_only" ||
(params.replyOptions?.sourceReplyDeliveryMode === undefined &&
ctx.CommandSource !== "native" &&
!isExplicitSourceReplyCommand(ctx) &&
(chatType === "group" || chatType === "channel"
? effectiveVisibleReplies !== "automatic"
: effectiveVisibleReplies === "message_tool"));
@@ -1516,6 +1519,9 @@ export async function dispatchReplyFromConfig(
}
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
const beforeAgentRunBlocked = replies.some(
(reply) => getReplyPayloadMetadata(reply)?.beforeAgentRunBlocked === true,
);
let queuedFinal = false;
let routedFinalCount = 0;
@@ -1619,7 +1625,11 @@ export async function dispatchReplyFromConfig(
pluginFallbackReason ? { reason: pluginFallbackReason } : undefined,
);
markIdle("message_completed");
return attachSourceReplyDeliveryMode({ queuedFinal, counts });
return attachSourceReplyDeliveryMode({
queuedFinal,
counts,
...(beforeAgentRunBlocked ? { beforeAgentRunBlocked } : {}),
});
} catch (err) {
if (inboundDedupeClaim.status === "claimed") {
if (inboundDedupeReplayUnsafe) {

View File

@@ -10,6 +10,7 @@ export type DispatchFromConfigResult = {
counts: Record<ReplyDispatchKind, number>;
failedCounts?: Partial<Record<ReplyDispatchKind, number>>;
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
beforeAgentRunBlocked?: boolean;
};
export type DispatchFromConfigParams = {

View File

@@ -97,13 +97,23 @@ describe("resolveSourceReplyDeliveryMode", () => {
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: { ChatType: "group", CommandSource: "text", CommandAuthorized: true },
ctx: {
ChatType: "group",
CommandSource: "text",
CommandAuthorized: true,
CommandBody: "/status",
},
}),
).toBe("automatic");
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: { ChatType: "group", CommandSource: "text" },
ctx: {
ChatType: "group",
CommandSource: "text",
CommandAuthorized: false,
CommandBody: "/status",
},
}),
).toBe("message_tool_only");
});
@@ -192,7 +202,12 @@ describe("resolveSourceReplyVisibilityPolicy", () => {
it("keeps native and authorized text command replies visible in groups", () => {
for (const ctx of [
{ ChatType: "group", CommandSource: "native" },
{ ChatType: "group", CommandSource: "text", CommandAuthorized: true },
{
ChatType: "group",
CommandSource: "text",
CommandAuthorized: true,
CommandBody: "/status",
},
] as const) {
expect(
resolveSourceReplyVisibilityPolicy({

View File

@@ -6,9 +6,17 @@ import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
export type SourceReplyDeliveryModeContext = {
ChatType?: string;
CommandAuthorized?: boolean;
CommandBody?: string;
CommandSource?: "text" | "native";
};
export function isExplicitSourceReplyCommand(ctx: SourceReplyDeliveryModeContext): boolean {
if (ctx.CommandSource === "native") {
return true;
}
return ctx.CommandSource === "text" && ctx.CommandAuthorized === true;
}
export function resolveSourceReplyDeliveryMode(params: {
cfg: OpenClawConfig;
ctx: SourceReplyDeliveryModeContext;
@@ -21,10 +29,7 @@ export function resolveSourceReplyDeliveryMode(params: {
? "automatic"
: params.requested;
}
if (
params.ctx.CommandSource === "native" ||
(params.ctx.CommandSource === "text" && params.ctx.CommandAuthorized === true)
) {
if (isExplicitSourceReplyCommand(params.ctx)) {
return "automatic";
}
const chatType = normalizeChatType(params.ctx.ChatType);

View File

@@ -1242,7 +1242,7 @@ export const FIELD_HELP: Record<string, string> = {
"plugins.entries.*.hooks.allowPromptInjection":
"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
"plugins.entries.*.hooks.allowConversationAccess":
"Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`. Non-bundled plugins must opt in explicitly.",
"Controls whether this plugin may read raw conversation content from typed hooks such as `before_agent_run`, `before_model_resolve`, `before_agent_reply`, `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`. Non-bundled plugins must opt in explicitly.",
"plugins.entries.*.hooks.timeoutMs":
"Default timeout in milliseconds for this plugin's typed hooks, capped at 600000. Use this to bound slow plugin hooks without changing plugin code; per-hook values in hooks.timeouts take precedence.",
"plugins.entries.*.hooks.timeouts":

View File

@@ -4,7 +4,9 @@ export type PluginEntryConfig = {
/** Controls prompt mutation via before_prompt_build and prompt fields from legacy before_agent_start. */
allowPromptInjection?: boolean;
/**
* Controls access to raw conversation content from llm_input/llm_output/agent_end hooks.
* Controls access to raw conversation content from conversation hooks including
* before_agent_run, before_model_resolve, before_agent_reply, llm_input, llm_output,
* before_agent_finalize, and agent_end.
* Non-bundled plugins must opt in explicitly; bundled plugins stay allowed unless disabled.
*/
allowConversationAccess?: boolean;

View File

@@ -47,7 +47,9 @@ const mockState = vi.hoisted(() => ({
};
}>,
dispatchError: null as Error | null,
dispatchErrorAfterAgentRunStart: null as Error | null,
triggerAgentRunStart: false,
onAfterAgentRunStart: null as (() => void) | null,
agentRunId: "run-agent-1",
sessionEntry: {} as Record<string, unknown>,
lastDispatchCtx: undefined as MsgContext | undefined,
@@ -69,6 +71,8 @@ const mockState = vi.hoisted(() => ({
sandboxWorkspace: null as { workspaceDir: string; containerWorkdir?: string } | null,
stageSandboxMediaError: null as Error | null,
stagedRelativePaths: null as string[] | null,
hasBeforeAgentRunHooks: false,
dispatchBlockedByBeforeAgentRun: false,
// `unstagedSources` lets tests simulate partial staging failure: absolute
// source paths listed here are excluded from the returned `staged` map even
// though ctx still carries their rewritten paths. This mirrors how the real
@@ -176,6 +180,10 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
}
if (mockState.triggerAgentRunStart) {
params.replyOptions?.onAgentRunStart?.(mockState.agentRunId);
mockState.onAfterAgentRunStart?.();
}
if (mockState.dispatchErrorAfterAgentRunStart) {
throw mockState.dispatchErrorAfterAgentRunStart;
}
if (mockState.dispatchedReplies.length > 0) {
for (const reply of mockState.dispatchedReplies) {
@@ -194,7 +202,12 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
}
params.dispatcher.markComplete();
await params.dispatcher.waitForIdle();
return { ok: true };
return {
ok: true,
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
...(mockState.dispatchBlockedByBeforeAgentRun ? { beforeAgentRunBlocked: true } : {}),
};
},
),
}));
@@ -212,6 +225,13 @@ vi.mock("../../infra/outbound/session-binding-service.js", async () => {
};
});
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => ({
hasHooks: (hookName: string) =>
hookName === "before_agent_run" && mockState.hasBeforeAgentRunHooks,
}),
}));
vi.mock("../../sessions/transcript-events.js", () => ({
emitSessionTranscriptUpdate: vi.fn(
(update: {
@@ -501,8 +521,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
mockState.finalPayload = null;
mockState.dispatchedReplies = [];
mockState.dispatchError = null;
mockState.dispatchErrorAfterAgentRunStart = null;
mockState.mainSessionKey = "main";
mockState.triggerAgentRunStart = false;
mockState.onAfterAgentRunStart = null;
mockState.agentRunId = "run-agent-1";
mockState.sessionEntry = {};
mockState.lastDispatchCtx = undefined;
@@ -523,6 +545,8 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
mockState.stagedRelativePaths = null;
mockState.unstagedSources = null;
mockState.deleteMediaBufferCalls = [];
mockState.hasBeforeAgentRunHooks = false;
mockState.dispatchBlockedByBeforeAgentRun = false;
});
it("registers tool-event recipients for clients advertising tool-events capability", async () => {
@@ -2058,6 +2082,92 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
expect(finalBroadcast).toBeUndefined();
});
it("does not emit pre-gate user transcript content when before_agent_run hooks are registered", async () => {
createTranscriptFixture("openclaw-chat-send-user-transcript-before-run-gate-");
mockState.finalText = "ok";
mockState.triggerAgentRunStart = true;
mockState.hasBeforeAgentRunHooks = true;
let userUpdateCountAtAgentStart = 0;
mockState.onAfterAgentRunStart = () => {
userUpdateCountAtAgentStart = mockState.emittedTranscriptUpdates.filter(
(update) =>
typeof update.message === "object" &&
update.message !== null &&
(update.message as { role?: unknown }).role === "user",
).length;
};
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-user-transcript-before-run-gate",
message: "secret prompt that may be blocked",
expectBroadcast: false,
});
expect(userUpdateCountAtAgentStart).toBe(0);
const userUpdates = mockState.emittedTranscriptUpdates.filter(
(update) =>
typeof update.message === "object" &&
update.message !== null &&
(update.message as { role?: unknown }).role === "user",
);
expect(userUpdates).toHaveLength(0);
});
it("does not emit raw user transcript content when before_agent_run blocks without a persisted marker", async () => {
createTranscriptFixture("openclaw-chat-send-user-transcript-blocked-live-signal-");
mockState.finalText = "The agent cannot read this message.";
mockState.triggerAgentRunStart = true;
mockState.hasBeforeAgentRunHooks = true;
mockState.dispatchBlockedByBeforeAgentRun = true;
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-user-transcript-blocked-live-signal",
message: "secret prompt blocked before persistence",
expectBroadcast: false,
});
const userUpdates = mockState.emittedTranscriptUpdates.filter(
(update) =>
typeof update.message === "object" &&
update.message !== null &&
(update.message as { role?: unknown }).role === "user",
);
expect(userUpdates).toHaveLength(0);
});
it("does not emit live user transcript content when before_agent_run hooks are present and the agent fails", async () => {
createTranscriptFixture("openclaw-chat-send-user-transcript-gate-pass-error-");
mockState.triggerAgentRunStart = true;
mockState.hasBeforeAgentRunHooks = true;
mockState.dispatchErrorAfterAgentRunStart = new Error("model unavailable");
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-user-transcript-gate-pass-error",
message: "prompt allowed before model error",
expectBroadcast: false,
});
const userUpdates = mockState.emittedTranscriptUpdates.filter(
(update) =>
typeof update.message === "object" &&
update.message !== null &&
(update.message as { role?: unknown }).role === "user",
);
expect(userUpdates).toHaveLength(0);
});
it("adds persisted media paths to the user transcript update", async () => {
createTranscriptFixture("openclaw-chat-send-user-transcript-images-");
mockState.finalText = "ok";

View File

@@ -35,6 +35,7 @@ import {
} from "../../media/store.js";
import { createChannelMessageReplyPipeline } from "../../plugin-sdk/channel-message.js";
import { isPluginOwnedSessionBindingRecord } from "../../plugins/conversation-binding.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
@@ -2258,6 +2259,8 @@ export const chatHandlers: GatewayRequestHandlers = {
const deliveredReplies: Array<{ payload: ReplyPayload; kind: "block" | "final" }> = [];
let appendedWebchatAgentMedia = false;
let userTranscriptUpdatePromise: Promise<void> | null = null;
let agentRunStarted = false;
const hasBeforeAgentRunGate = getGlobalHookRunner()?.hasHooks("before_agent_run") === true;
const emitUserTranscriptUpdate = async () => {
if (userTranscriptUpdatePromise) {
await userTranscriptUpdatePromise;
@@ -2432,16 +2435,6 @@ export const chatHandlers: GatewayRequestHandlers = {
},
});
// Surface accepted inbound turns immediately so transcript subscribers
// (gateway watchers, MCP bridges, external channel backends) do not wait
// on model startup, completion, or failure paths before seeing the user turn.
void emitUserTranscriptUpdate().catch((transcriptErr) => {
context.logGateway.warn(
`webchat eager user transcript update failed: ${formatForLog(transcriptErr)}`,
);
});
let agentRunStarted = false;
void dispatchInboundMessage({
ctx,
cfg,
@@ -2453,7 +2446,9 @@ export const chatHandlers: GatewayRequestHandlers = {
imageOrder: imageOrder.length > 0 ? imageOrder : undefined,
onAgentRunStart: (runId) => {
agentRunStarted = true;
void emitUserTranscriptUpdate();
if (!hasBeforeAgentRunGate) {
void emitUserTranscriptUpdate();
}
const connId = typeof client?.connId === "string" ? client.connId : undefined;
const wantsToolEvents = hasGatewayClientCap(
client?.connect?.caps,
@@ -2654,8 +2649,12 @@ export const chatHandlers: GatewayRequestHandlers = {
message,
});
}
} else {
void emitUserTranscriptUpdate();
} else if (!hasBeforeAgentRunGate) {
await emitUserTranscriptUpdate().catch((transcriptErr) => {
context.logGateway.warn(
`webchat user transcript update failed after agent run: ${formatForLog(transcriptErr)}`,
);
});
}
if (!context.chatAbortedRuns.has(clientRunId)) {
setGatewayDedupeEntry({
@@ -2669,13 +2668,17 @@ export const chatHandlers: GatewayRequestHandlers = {
});
}
})
.catch((err) => {
.catch(async (err) => {
void rewriteUserTranscriptMedia().catch((rewriteErr) => {
context.logGateway.warn(
`webchat transcript media rewrite failed after error: ${formatForLog(rewriteErr)}`,
);
});
void emitUserTranscriptUpdate().catch((transcriptErr) => {
const emitAfterError =
agentRunStarted && hasBeforeAgentRunGate
? Promise.resolve()
: emitUserTranscriptUpdate();
await emitAfterError.catch((transcriptErr) => {
context.logGateway.warn(
`webchat user transcript update failed after error: ${formatForLog(transcriptErr)}`,
);

View File

@@ -275,7 +275,9 @@ export class SessionHistorySseState {
this.target.sessionId,
this.target.storePath,
this.target.sessionFile,
resolveSessionHistoryTailReadOptions(this.limit),
{
...resolveSessionHistoryTailReadOptions(this.limit),
},
);
return {
rawMessages: snapshot.messages,

View File

@@ -286,6 +286,97 @@ describe("session.message websocket events", () => {
}
});
test("strips blocked original content from live session.message events", async () => {
const storePath = await createSessionStoreFile();
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
storePath,
});
const transcriptPath = path.join(path.dirname(storePath), "sess-main.jsonl");
await fs.writeFile(
transcriptPath,
JSON.stringify({ type: "session", version: 1, id: "sess-main" }) + "\n",
"utf-8",
);
await withOperatorSessionSubscriber(async (ws) => {
const { messageEvent } = await emitTranscriptUpdateAndCollectEvents({
ws,
sessionKey: "agent:main:main",
sessionFile: transcriptPath,
messageId: "blocked-1",
message: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
beforeAgentRunBlocked: { blockedBy: "policy-plugin", blockedAt: 1 },
},
},
});
const payload = messageEvent.payload as {
message?: { content?: unknown; __openclaw?: { beforeAgentRunBlocked?: unknown } };
};
expect(payload.message?.content).toEqual([
{ type: "text", text: "The agent cannot read this message." },
]);
expect(JSON.stringify(payload.message)).not.toContain("secret blocked prompt");
expect(JSON.stringify(payload.message)).not.toContain("contains protected content");
});
});
test("broadcasts redacted blocked user appends to live session listeners", async () => {
const storePath = await createSessionStoreFile();
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
storePath,
});
await withOperatorSessionSubscriber(async (ws) => {
const messageEventPromise = waitForSessionMessageEvent(ws, "agent:main:main");
emitSessionTranscriptUpdate({
sessionFile: path.join(path.dirname(storePath), "sess-main.jsonl"),
sessionKey: "agent:main:main",
messageId: "blocked-message",
message: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
beforeAgentRunBlocked: {
blockedBy: "policy-plugin",
blockedAt: Date.now(),
},
},
},
});
const messageEvent = await messageEventPromise;
const payload = messageEvent.payload as {
message?: {
role?: unknown;
content?: unknown;
__openclaw?: { beforeAgentRunBlocked?: unknown };
};
};
expect(payload.message?.role).toBe("user");
expect(payload.message?.content).toEqual([
{ type: "text", text: "The agent cannot read this message." },
]);
expect(JSON.stringify(payload.message)).not.toContain("secret blocked prompt");
expect(JSON.stringify(payload.message)).not.toContain("contains protected content");
});
});
test("includes live usage metadata on session.message and sessions.changed transcript events", async () => {
const storePath = await createSessionStoreFile();
await writeSessionStore({

View File

@@ -29,6 +29,32 @@ import {
resolveSessionTranscriptCandidates,
} from "./session-utils.fs.js";
function buildSessionAssistantMessage(text: string, timestamp: number) {
return {
role: "assistant" as const,
content: [{ type: "text" as const, text }],
api: "openai",
provider: "openai",
model: "mock-1",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
stopReason: "stop" as const,
timestamp,
};
}
function registerTempSessionStore(
prefix: string,
assignPaths: (tmpDir: string, storePath: string) => void,
@@ -51,6 +77,30 @@ function writeTranscript(tmpDir: string, sessionId: string, lines: unknown[]): s
return transcriptPath;
}
function appendBlockedUserMessageWithSessionManager(params: {
sessionFile: string;
originalText?: string;
redactedText: string;
pluginId: string;
idempotencyKey?: string;
}): string {
const sessionManager = SessionManager.open(params.sessionFile, path.dirname(params.sessionFile));
const messageId = sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: params.redactedText }],
timestamp: Date.now(),
...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
__openclaw: {
beforeAgentRunBlocked: {
blockedBy: params.pluginId,
blockedAt: Date.now(),
},
},
} as Parameters<typeof sessionManager.appendMessage>[0]);
(sessionManager as unknown as { _rewriteFile?: () => void })._rewriteFile?.();
return messageId;
}
function buildBasicSessionTranscript(
sessionId: string,
userText = "Hello world",
@@ -1047,6 +1097,29 @@ describe("readSessionMessages", () => {
}
});
test("keeps legacy messages when a mixed transcript lacks a complete branch tree", () => {
const sessionId = "mixed-legacy-tree-session";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
{ type: "session", version: 1, id: sessionId },
{ type: "message", id: "legacy-user", message: { role: "user", content: "legacy hello" } },
{
type: "message",
id: "tree-assistant",
parentId: "legacy-user",
message: { role: "assistant", content: "tree hello" },
},
];
fs.writeFileSync(transcriptPath, lines.map((line) => JSON.stringify(line)).join("\n"), "utf-8");
const out = readSessionMessages(sessionId, storePath);
expect(out.map((message) => (message as { content?: unknown }).content)).toEqual([
"legacy hello",
"tree hello",
]);
});
test.each([
{
sessionId: "cross-agent-default-root",
@@ -1081,6 +1154,215 @@ describe("readSessionMessages", () => {
expect((out[0] as { __openclaw?: { seq?: number } }).__openclaw?.seq).toBe(1);
},
);
test("reads only the active SessionManager branch after a transcript rewrite", () => {
const sessionId = "branched-session";
const sessionManager = SessionManager.create(tmpDir, tmpDir);
const decoratedPrompt = 'Sender (untrusted metadata):\n```json\n{"label":"ui"}\n```\n\nhello';
const visiblePrompt = "hello";
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: decoratedPrompt }],
timestamp: 1,
});
sessionManager.appendMessage(buildSessionAssistantMessage("old answer", 2));
const decoratedUser = sessionManager
.getBranch()
.find((entry) => entry.type === "message" && entry.message.role === "user");
expect(decoratedUser?.type).toBe("message");
if (decoratedUser?.parentId) {
sessionManager.branch(decoratedUser.parentId);
} else {
sessionManager.resetLeaf();
}
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: visiblePrompt }],
timestamp: 1,
});
sessionManager.appendMessage(buildSessionAssistantMessage("old answer", 2));
const sessionFile = sessionManager.getSessionFile();
expect(sessionFile).toBeTruthy();
const out = readSessionMessages(sessionId, storePath, sessionFile ?? undefined);
expect(
out.map((message) => ({
role: (message as { role?: string }).role,
content: (message as { content?: unknown }).content,
})),
).toEqual([
{ role: "user", content: [{ type: "text", text: visiblePrompt }] },
{ role: "assistant", content: [{ type: "text", text: "old answer" }] },
]);
});
test("keeps compaction markers when reading only the active SessionManager branch", () => {
const sessionId = "branched-session-with-compaction";
const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
{
type: "session",
version: 1,
id: sessionId,
},
{
type: "message",
id: "user-old",
parentId: null,
message: { role: "user", content: "old prompt", timestamp: 1 },
},
{
type: "message",
id: "assistant-old",
parentId: "user-old",
message: { role: "assistant", content: "old answer", timestamp: 2 },
},
{
type: "compaction",
id: "comp-1",
timestamp: "2026-02-07T00:00:00.000Z",
summary: "Compacted history",
},
{
type: "message",
id: "user-active",
parentId: null,
message: { role: "user", content: "active prompt", timestamp: 3 },
},
{
type: "message",
id: "assistant-active",
parentId: "user-active",
message: { role: "assistant", content: "active answer", timestamp: 4 },
},
];
fs.writeFileSync(sessionFile, lines.map((line) => JSON.stringify(line)).join("\n"), "utf-8");
const out = readSessionMessages(sessionId, storePath, sessionFile);
expect(
out.map((message) => ({
role: (message as { role?: string }).role,
content: (message as { content?: unknown }).content,
kind: (message as { __openclaw?: { kind?: string } }).__openclaw?.kind,
})),
).toEqual([
{ role: "system", content: [{ type: "text", text: "Compaction" }], kind: "compaction" },
{ role: "user", content: "active prompt", kind: undefined },
{ role: "assistant", content: "active answer", kind: undefined },
]);
});
test("keeps blocked hook messages on the current active branch", async () => {
const sessionId = "blocked-hook-branch-session";
const sessionKey = "agent:main:explicit:blocked-hook-branch";
const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`);
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: {
sessionId,
updatedAt: 1,
sessionFile,
},
}),
"utf-8",
);
fs.writeFileSync(
sessionFile,
[
{ type: "session", version: 1, id: sessionId },
{
type: "message",
id: "user-1",
parentId: null,
message: { role: "user", content: "hello", timestamp: 1 },
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
message: { role: "assistant", content: "hi", timestamp: 2 },
},
]
.map((line) => JSON.stringify(line))
.join("\n") + "\n",
"utf-8",
);
const messageId = appendBlockedUserMessageWithSessionManager({
sessionFile,
originalText: "[hitl:block] hello",
redactedText: "Blocked by HITL test hook.",
pluginId: "hitl-test-hooks",
});
expect(messageId).toBeTruthy();
const out = readSessionMessages(sessionId, storePath, sessionFile);
expect(
out.map((message) => ({
role: (message as { role?: string }).role,
text: (message as { content?: string | Array<{ text?: string }> }).content,
})),
).toEqual([
{ role: "user", text: "hello" },
{ role: "assistant", text: "hi" },
{ role: "user", text: [{ type: "text", text: "Blocked by HITL test hook." }] },
]);
expect(JSON.stringify(out)).not.toContain("[hitl:block] hello");
expect(JSON.stringify(out)).not.toContain("matched original");
});
test("keeps repeated blocked hook messages together in a new session", async () => {
const sessionKey = "agent:main:explicit:repeated-blocked-hook";
const sessionManager = SessionManager.create(tmpDir, tmpDir);
const sessionId = sessionManager.getSessionId();
const sessionFile = sessionManager.getSessionFile();
if (!sessionFile) {
throw new Error("expected SessionManager.create to return a session file");
}
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: {
sessionId,
updatedAt: 1,
sessionFile,
},
}),
"utf-8",
);
appendBlockedUserMessageWithSessionManager({
sessionFile,
originalText: "[hitl:block] first",
redactedText: "Blocked by HITL test hook.",
pluginId: "hitl-test-hooks",
});
appendBlockedUserMessageWithSessionManager({
sessionFile,
originalText: "[hitl:block] second",
redactedText: "Blocked again by HITL test hook.",
pluginId: "hitl-test-hooks",
});
const out = readSessionMessages(sessionId, storePath, sessionFile);
expect(
out.map((message) => ({
role: (message as { role?: string }).role,
text: (message as { content?: Array<{ text?: string }> }).content?.[0]?.text,
})),
).toEqual([
{ role: "user", text: "Blocked by HITL test hook." },
{ role: "user", text: "Blocked again by HITL test hook." },
]);
expect(JSON.stringify(out)).not.toContain("[hitl:block] first");
expect(JSON.stringify(out)).not.toContain("[hitl:block] second");
expect(JSON.stringify(out)).not.toContain("matched original");
});
});
describe("readSessionPreviewItemsFromTranscript", () => {

View File

@@ -360,8 +360,10 @@ function selectBoundedActiveTailRecords(entries: TailTranscriptRecord[]): TailTr
const byId = new Map<string, TailTranscriptRecord>();
let leafId: string | undefined;
for (const entry of entries) {
if (tailRecordHasTreeLink(entry) && entry.id) {
if (entry.id) {
byId.set(entry.id, entry);
}
if (tailRecordHasTreeLink(entry) && entry.id) {
leafId = entry.id;
}
}
@@ -384,7 +386,18 @@ function selectBoundedActiveTailRecords(entries: TailTranscriptRecord[]): TailTr
selected.push(entry);
currentId = entry.parentId ?? undefined;
}
return selected.toReversed();
const activeBranch = selected.toReversed();
const firstActiveRecord = activeBranch[0];
const firstActiveIndex = firstActiveRecord ? entries.indexOf(firstActiveRecord) : -1;
if (firstActiveIndex > 0) {
for (let index = firstActiveIndex - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry?.record.type === "compaction") {
return [entry, ...activeBranch];
}
}
}
return activeBranch;
}
function readTranscriptRecords(filePath: string): TailTranscriptRecord[] {

View File

@@ -100,7 +100,7 @@ vi.mock("./session-history-state.js", () => ({
history: { items: [], nextCursor: null, messages: [] },
}),
SessionHistorySseState: {
fromRawSnapshot: () => ({
fromRawSnapshot: (_params: unknown) => ({
snapshot: () => ({ items: [], nextCursor: null, messages: [] }),
appendInlineMessage: ({ message, messageId }: { message: unknown; messageId?: string }) => ({
message,

View File

@@ -388,8 +388,9 @@ export type DiagnosticRunStartedEvent = DiagnosticRunBaseEvent & {
export type DiagnosticRunCompletedEvent = DiagnosticRunBaseEvent & {
type: "run.completed";
durationMs: number;
outcome: "completed" | "aborted" | "error";
outcome: "completed" | "aborted" | "blocked" | "error";
errorCategory?: string;
blockedBy?: string;
};
export type DiagnosticHarnessRunPhase = "prepare" | "start" | "send" | "resolve" | "cleanup";

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
BLOCK_MESSAGE_PREFIX,
type HookDecision,
type HookDecisionBlock,
mergeHookDecisions,
isHookDecision,
resolveBlockMessage,
} from "./hook-decision-types.js";
describe("HookDecision helpers", () => {
describe("isHookDecision", () => {
it("recognizes supported outcomes", () => {
expect(isHookDecision({ outcome: "pass" })).toBe(true);
expect(isHookDecision({ outcome: "block", reason: "policy" })).toBe(true);
});
it("rejects non-decision values", () => {
expect(isHookDecision(null)).toBe(false);
expect(isHookDecision(undefined)).toBe(false);
expect(isHookDecision("pass")).toBe(false);
expect(isHookDecision({ block: true })).toBe(false);
expect(isHookDecision({ outcome: "ask", reason: "check" })).toBe(false);
expect(isHookDecision({ outcome: "invalid" })).toBe(false);
expect(isHookDecision({ outcome: "pass", message: "typo" })).toBe(false);
expect(isHookDecision({ outcome: "pass", reason: "typo" })).toBe(false);
expect(isHookDecision({ outcome: "block" })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "" })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "policy", message: "" })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "policy", message: 3 })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "policy", ask: true })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "policy", metadata: [] })).toBe(false);
});
});
describe("mergeHookDecisions", () => {
const passDecision: HookDecision = { outcome: "pass" };
const blockDecision: HookDecision = { outcome: "block", reason: "policy" };
it("uses most-restrictive-wins ordering", () => {
expect(mergeHookDecisions(undefined, passDecision)).toBe(passDecision);
expect(mergeHookDecisions(passDecision, blockDecision)).toBe(blockDecision);
expect(mergeHookDecisions(blockDecision, passDecision)).toBe(blockDecision);
});
it("keeps the first decision when outcomes have the same severity", () => {
const secondBlock: HookDecision = { outcome: "block", reason: "second" };
expect(mergeHookDecisions(passDecision, { outcome: "pass" })).toBe(passDecision);
expect(mergeHookDecisions(blockDecision, secondBlock)).toBe(blockDecision);
});
});
describe("resolveBlockMessage", () => {
it("returns explicit or default block messages", () => {
const explicit: HookDecisionBlock = {
outcome: "block",
reason: "policy",
message: "Please rephrase your request.",
};
const fallback: HookDecisionBlock = {
outcome: "block",
reason: "policy",
};
expect(resolveBlockMessage(explicit)).toBe(
`${BLOCK_MESSAGE_PREFIX}: Please rephrase your request.`,
);
expect(resolveBlockMessage(fallback)).toBe(`${BLOCK_MESSAGE_PREFIX}: blocked`);
expect(resolveBlockMessage(fallback, { blockedBy: "policy-plugin" })).toBe(
`${BLOCK_MESSAGE_PREFIX}: blocked by policy-plugin`,
);
expect(resolveBlockMessage(explicit, { blockedBy: "policy-plugin" })).toBe(
`${BLOCK_MESSAGE_PREFIX}: Please rephrase your request. (blocked by policy-plugin)`,
);
expect(resolveBlockMessage({ ...explicit, message: " " })).toBe(
`${BLOCK_MESSAGE_PREFIX}: blocked`,
);
});
});
});

View File

@@ -0,0 +1,113 @@
/**
* Structured decision returned by gate/policy hooks.
* Core is outcome-agnostic — it handles the mechanics of each outcome
* without knowing *why* the decision was made.
*/
export type HookDecision = HookDecisionPass | HookDecisionBlock;
/** Content is fine. Proceed normally. */
export type HookDecisionPass = {
outcome: "pass";
};
/** Prefix for user-facing replacement messages when a `block` decision stops a request. */
export const BLOCK_MESSAGE_PREFIX = "Your message could not be sent";
/**
* Content is blocked. `reason` is internal plugin-local detail; core must not log,
* persist, broadcast, or expose it verbatim. `message` is user-facing detail.
*/
export type HookDecisionBlock = {
outcome: "block";
/** Internal plugin-local reason. Do not log, persist, broadcast, or expose verbatim. */
reason: string;
/** Optional user-facing detail included in the block response envelope. */
message?: string;
/** Plugin-defined category for analytics (e.g. "violence", "pii", "cost_limit"). */
category?: string;
/** Opaque metadata for the plugin's own use. Core does not interpret it. */
metadata?: Record<string, unknown>;
};
export function resolveBlockMessage(
decision: HookDecisionBlock,
params: { blockedBy?: string } = {},
): string {
const message = typeof decision.message === "string" ? decision.message.trim() : "";
const blockedBy = params.blockedBy?.trim();
if (message) {
return blockedBy
? `${BLOCK_MESSAGE_PREFIX}: ${message} (blocked by ${blockedBy})`
: `${BLOCK_MESSAGE_PREFIX}: ${message}`;
}
return blockedBy
? `${BLOCK_MESSAGE_PREFIX}: blocked by ${blockedBy}`
: `${BLOCK_MESSAGE_PREFIX}: blocked`;
}
/** Outcome severity for most-restrictive-wins merging. Higher = more restrictive. */
export const HOOK_DECISION_SEVERITY: Record<HookDecision["outcome"], number> = {
pass: 0,
block: 2,
};
/**
* Merge two HookDecisions using most-restrictive-wins semantics.
* `block > pass`
*/
export function mergeHookDecisions(a: HookDecision | undefined, b: HookDecision): HookDecision {
if (!a) {
return b;
}
return HOOK_DECISION_SEVERITY[b.outcome] > HOOK_DECISION_SEVERITY[a.outcome] ? b : a;
}
/**
* Type guard: does this object look like a HookDecision (has `outcome` field)?
*/
export function isHookDecision(value: unknown): value is HookDecision {
if (typeof value !== "object" || value === null) {
return false;
}
const v = value as Record<string, unknown>;
const keys = Object.keys(v);
if (v.outcome === "pass") {
return keys.length === 1;
}
if (v.outcome !== "block") {
return false;
}
const allowedBlockKeys = new Set(["outcome", "reason", "message", "category", "metadata"]);
if (keys.some((key) => !allowedBlockKeys.has(key))) {
return false;
}
if (typeof v.reason !== "string" || !v.reason.trim()) {
return false;
}
if ("message" in v && (typeof v.message !== "string" || !v.message.trim())) {
return false;
}
if ("category" in v && (typeof v.category !== "string" || !v.category.trim())) {
return false;
}
if (
"metadata" in v &&
(typeof v.metadata !== "object" || v.metadata === null || Array.isArray(v.metadata))
) {
return false;
}
return true;
}
/** Outcomes valid for input gates (before_agent_run). */
export type InputGateDecision = HookDecisionPass | HookDecisionBlock;
/**
* A gate hook decision paired with the pluginId that produced it.
* Returned by gate hook runners so callers can
* attribute blocked entries and audit events to the originating plugin.
*/
export type GateHookResult<TDecision extends HookDecision = HookDecision> = {
decision: TDecision;
pluginId: string;
};

View File

@@ -0,0 +1,365 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GlobalHookRunnerRegistry } from "./hook-registry.types.js";
import type { PluginHookRegistration, PluginHookAgentContext } from "./hook-types.js";
import { createHookRunner } from "./hooks.js";
function makeRegistry(hooks: PluginHookRegistration[] = []): GlobalHookRunnerRegistry {
return {
hooks: [],
typedHooks: hooks,
plugins: [],
};
}
const ctx: PluginHookAgentContext = {
runId: "run-1",
agentId: "agent-1",
sessionKey: "session-1",
sessionId: "sid-1",
};
describe("before_agent_run hook", () => {
afterEach(() => {
vi.useRealTimers();
});
it("returns undefined when no handlers registered", async () => {
const runner = createHookRunner(makeRegistry());
const result = await runner.runBeforeAgentRun({ prompt: "hello", messages: [] }, ctx);
expect(result).toBeUndefined();
});
it("returns pass when handler returns pass", async () => {
const registry = makeRegistry([
{
pluginId: "test",
hookName: "before_agent_run",
handler: async () => ({ outcome: "pass" as const }),
source: "test",
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "hello", messages: [] }, ctx);
expect(result?.decision).toEqual({ outcome: "pass" });
expect(result?.pluginId).toBe("test");
});
it("returns block when handler returns block (with `message`)", async () => {
const registry = makeRegistry([
{
pluginId: "test",
hookName: "before_agent_run",
handler: async () => ({
outcome: "block" as const,
reason: "unsafe content",
message: "I can't process that.",
category: "violence",
}),
source: "test",
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "bad stuff", messages: [] }, ctx);
expect(result?.decision.outcome).toBe("block");
if (result?.decision.outcome === "block") {
expect(result.decision.reason).toBe("unsafe content");
expect(result.decision.message).toBe("I can't process that.");
}
});
it("blocks when one of multiple handlers passes and a later handler blocks", async () => {
const calls: string[] = [];
const passHandler = vi.fn(async () => {
calls.push("pass-plugin");
return { outcome: "pass" as const };
});
const blockHandler = vi.fn(async () => {
calls.push("block-plugin");
return {
outcome: "block" as const,
reason: "blocked",
};
});
const registry = makeRegistry([
{
pluginId: "pass-plugin",
hookName: "before_agent_run",
handler: passHandler,
source: "test",
priority: 10,
},
{
pluginId: "block-plugin",
hookName: "before_agent_run",
handler: blockHandler,
source: "test",
priority: 5,
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
expect(result?.decision.outcome).toBe("block");
expect(result?.pluginId).toBe("block-plugin");
expect(passHandler).toHaveBeenCalledTimes(1);
expect(blockHandler).toHaveBeenCalledTimes(1);
expect(calls).toEqual(["pass-plugin", "block-plugin"]);
});
it("short-circuits when the first of multiple handlers blocks", async () => {
const blockHandler = vi.fn(async () => ({
outcome: "block" as const,
reason: "blocked",
}));
const passHandler = vi.fn(async () => ({ outcome: "pass" as const }));
const registry = makeRegistry([
{
pluginId: "block-plugin",
hookName: "before_agent_run",
handler: blockHandler,
source: "test",
priority: 10,
},
{
pluginId: "pass-plugin",
hookName: "before_agent_run",
handler: passHandler,
source: "test",
priority: 5,
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
expect(result?.decision.outcome).toBe("block");
expect(result?.pluginId).toBe("block-plugin");
expect(blockHandler).toHaveBeenCalledTimes(1);
expect(passHandler).not.toHaveBeenCalled();
});
it("treats void handler returns as pass (no effect)", async () => {
const registry = makeRegistry([
{
pluginId: "void-plugin",
hookName: "before_agent_run",
handler: async () => undefined,
source: "test",
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
// void => undefined result (no decision)
expect(result).toBeUndefined();
});
it("fails closed on invalid handler results", async () => {
const registry = makeRegistry([
{
pluginId: "invalid-plugin",
hookName: "before_agent_run",
handler: async () => ({ block: true }) as never,
source: "test",
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
expect(result).toEqual({
decision: {
outcome: "block",
reason: "before_agent_run returned an invalid decision",
},
pluginId: "invalid-plugin",
});
});
it("fails closed on null handler results", async () => {
const registry = makeRegistry([
{
pluginId: "null-plugin",
hookName: "before_agent_run",
handler: async () => null as never,
source: "test",
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
expect(result).toEqual({
decision: {
outcome: "block",
reason: "before_agent_run returned an invalid decision",
},
pluginId: "null-plugin",
});
});
it("fails closed on malformed block decisions", async () => {
const registry = makeRegistry([
{
pluginId: "malformed-block-plugin",
hookName: "before_agent_run",
handler: async () => ({ outcome: "block" }) as never,
source: "test",
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
expect(result).toEqual({
decision: {
outcome: "block",
reason: "before_agent_run returned an invalid decision",
},
pluginId: "malformed-block-plugin",
});
});
it("fails closed when handlers throw", async () => {
const registry = makeRegistry([
{
pluginId: "throwing-plugin",
hookName: "before_agent_run",
handler: async () => {
throw new Error("policy unavailable");
},
source: "test",
},
]);
const runner = createHookRunner(registry);
await expect(runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx)).rejects.toThrow(
"before_agent_run handler from throwing-plugin failed: policy unavailable",
);
});
it("fails closed when handlers exceed the default timeout", async () => {
vi.useFakeTimers();
const registry = makeRegistry([
{
pluginId: "hanging-plugin",
hookName: "before_agent_run",
handler: async () => await new Promise<never>(() => {}),
source: "test",
},
]);
const runner = createHookRunner(registry);
const resultPromise = runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
const rejection = expect(resultPromise).rejects.toThrow(
"before_agent_run handler from hanging-plugin failed: timed out after 15000ms",
);
await vi.advanceTimersByTimeAsync(15_000);
await rejection;
});
it("receives the correct event payload", async () => {
let receivedEvent: unknown;
const registry = makeRegistry([
{
pluginId: "test",
hookName: "before_agent_run",
handler: async (event: unknown) => {
receivedEvent = event;
return { outcome: "pass" as const };
},
source: "test",
},
]);
const runner = createHookRunner(registry);
await runner.runBeforeAgentRun(
{
prompt: "hello world",
messages: [{ role: "user", content: "hello" }],
channelId: "discord",
senderId: "user-123",
senderIsOwner: true,
},
ctx,
);
const event = receivedEvent as Record<string, unknown>;
expect(event.prompt).toBe("hello world");
expect(event.channelId).toBe("discord");
expect(event.senderId).toBe("user-123");
expect(event.senderIsOwner).toBe(true);
});
});
describe("before_agent_run invalid ask outcome", () => {
it("fails closed when handler returns ask", async () => {
const registry = makeRegistry([
{
pluginId: "test",
hookName: "before_agent_run",
handler: async () =>
({
outcome: "ask",
reason: "needs approval",
title: "Review Required",
description: "This prompt requires human review.",
}) as never,
source: "test",
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "hello", messages: [] }, ctx);
expect(result?.decision).toEqual({
outcome: "block",
reason: "before_agent_run returned an invalid decision",
});
expect(result?.pluginId).toBe("test");
});
it("short-circuits unsupported ask decisions", async () => {
let secondHandlerCalled = false;
const registry = makeRegistry([
{
pluginId: "plugin-a",
hookName: "before_agent_run",
handler: async () =>
({
outcome: "ask" as const,
reason: "check",
title: "Check",
description: "Check this.",
}) as never,
source: "test",
priority: 10,
},
{
pluginId: "plugin-b",
hookName: "before_agent_run",
handler: async () => {
secondHandlerCalled = true;
return { outcome: "pass" as const };
},
source: "test",
priority: 5,
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
expect(result?.decision.outcome).toBe("block");
expect(result?.pluginId).toBe("plugin-a");
expect(secondHandlerCalled).toBe(false);
});
});
describe("before_tool_call channelId forwarding", () => {
it("passes channelId through to before_tool_call handlers", async () => {
let receivedCtx: unknown;
const registry = makeRegistry([
{
pluginId: "test",
hookName: "before_tool_call",
handler: async (_event: unknown, ctx: unknown) => {
receivedCtx = ctx;
return undefined;
},
source: "test",
},
]);
const runner = createHookRunner(registry);
await runner.runBeforeToolCall(
{ toolName: "exec", params: {} },
{ toolName: "exec", channelId: "discord", sessionKey: "s1" },
);
expect((receivedCtx as { channelId?: string }).channelId).toBe("discord");
});
});

View File

@@ -41,6 +41,7 @@ export function initializeGlobalHookRunner(registry: GlobalHookRunnerRegistry):
},
catchErrors: true,
failurePolicyByHook: {
before_agent_run: "fail-closed",
before_tool_call: "fail-closed",
},
});

View File

@@ -21,6 +21,7 @@ import type {
PluginHookBeforePromptBuildEvent,
PluginHookBeforePromptBuildResult,
} from "./hook-before-agent-start.types.js";
import type { InputGateDecision } from "./hook-decision-types.js";
import type {
PluginHookInboundClaimContext,
PluginHookInboundClaimEvent,
@@ -103,7 +104,8 @@ export type PluginHookName =
| "cron_changed"
| "before_dispatch"
| "reply_dispatch"
| "before_install";
| "before_install"
| "before_agent_run";
export const PLUGIN_HOOK_NAMES = [
"before_model_resolve",
@@ -141,6 +143,7 @@ export const PLUGIN_HOOK_NAMES = [
"before_dispatch",
"reply_dispatch",
"before_install",
"before_agent_run",
] as const satisfies readonly PluginHookName[];
type MissingPluginHookNames = Exclude<PluginHookName, (typeof PLUGIN_HOOK_NAMES)[number]>;
@@ -168,10 +171,13 @@ export const isPromptInjectionHookName = (hookName: PluginHookName): boolean =>
promptInjectionHookNameSet.has(hookName);
export const CONVERSATION_HOOK_NAMES = [
"before_model_resolve",
"before_agent_reply",
"llm_input",
"llm_output",
"before_agent_finalize",
"agent_end",
"before_agent_run",
] as const satisfies readonly PluginHookName[];
export type ConversationHookName = (typeof CONVERSATION_HOOK_NAMES)[number];
@@ -259,6 +265,8 @@ export type PluginHookLlmOutputEvent = {
* `resolvedRef` so provider/model consumers keep a stable parse contract.
*/
harnessId?: string;
/** The original user prompt that produced this output. */
prompt?: string;
assistantTexts: string[];
lastAssistant?: unknown;
usage?: {
@@ -408,6 +416,7 @@ export type PluginHookToolContext = {
getSessionExtension?: <T extends PluginJsonValue = PluginJsonValue>(
namespace: string,
) => T | undefined;
channelId?: string;
};
export type PluginHookBeforeToolCallEvent = {
@@ -438,6 +447,7 @@ export type PluginHookBeforeToolCallResult = {
severity?: "info" | "warning" | "critical";
timeoutMs?: number;
timeoutBehavior?: "allow" | "deny";
allowedDecisions?: Array<"allow-once" | "allow-always" | "deny">;
pluginId?: string;
onResolution?: (decision: PluginApprovalResolution) => Promise<void> | void;
};
@@ -802,6 +812,31 @@ export type PluginHookBeforeInstallResult = {
blockReason?: string;
};
// ---------------------------------------------------------------------------
// before_agent_run — Lifecycle Gate Hook
// ---------------------------------------------------------------------------
/** Event payload for the before_agent_run gate hook. */
export type PluginHookBeforeAgentRunEvent = {
/** The user's message that triggered this run. */
prompt: string;
/** Loaded session history before the current prompt is submitted. */
messages: unknown[];
/** Active system prompt prepared for this run. */
systemPrompt?: string;
/** Account identity when available. */
accountId?: string;
/** Channel the message came from. */
channelId?: string;
/** Sender identity when available. */
senderId?: string;
/** Whether the sender is an owner. */
senderIsOwner?: boolean;
};
/** Result type for before_agent_run. Returns pass/block or void (= pass). */
export type PluginHookBeforeAgentRunResult = InputGateDecision | void;
export type PluginHookHandlerMap = {
agent_turn_prepare: (
event: PluginAgentTurnPrepareEvent,
@@ -950,6 +985,10 @@ export type PluginHookHandlerMap = {
event: PluginHookBeforeInstallEvent,
ctx: PluginHookBeforeInstallContext,
) => Promise<PluginHookBeforeInstallResult | void> | PluginHookBeforeInstallResult | void;
before_agent_run: (
event: PluginHookBeforeAgentRunEvent,
ctx: PluginHookAgentContext,
) => Promise<PluginHookBeforeAgentRunResult> | PluginHookBeforeAgentRunResult;
};
export type PluginHookRegistration<K extends PluginHookName = PluginHookName> = {

View File

@@ -2,12 +2,17 @@
* Plugin Hook Runner
*
* Provides utilities for executing plugin lifecycle hooks with proper
* error handling, priority ordering, and async support.
* error handling and priority ordering.
*/
import { formatHookErrorForLog } from "../hooks/fire-and-forget.js";
import { formatErrorMessage } from "../infra/errors.js";
import { concatOptionalTextSegments } from "../shared/text/join-segments.js";
import {
type GateHookResult,
type InputGateDecision,
isHookDecision,
} from "./hook-decision-types.js";
import type { GlobalHookRunnerRegistry, HookRunnerRegistry } from "./hook-registry.types.js";
import type {
PluginHookAfterCompactionEvent,
@@ -45,6 +50,7 @@ import type {
PluginAgentTurnPrepareResult,
PluginHeartbeatPromptContributionEvent,
PluginHeartbeatPromptContributionResult,
PluginHookBeforeAgentRunEvent,
PluginHookCronChangedEvent,
PluginHookGatewayCronDeliveryStatus,
PluginHookGatewayCronJobState,
@@ -118,6 +124,7 @@ export type {
PluginHookToolContext,
PluginHookBeforeToolCallEvent,
PluginHookBeforeToolCallResult,
PluginHookBeforeAgentRunEvent,
PluginHookAfterToolCallEvent,
PluginHookToolResultPersistContext,
PluginHookToolResultPersistEvent,
@@ -184,6 +191,7 @@ const DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK: Partial<Record<PluginHookName, numbe
agent_end: 30_000,
};
const DEFAULT_MODIFYING_HOOK_TIMEOUT_MS_BY_HOOK: Partial<Record<PluginHookName, number>> = {
before_agent_run: 15_000,
before_prompt_build: 15_000,
};
@@ -193,6 +201,7 @@ type ModifyingHookPolicy<K extends PluginHookName, TResult> = {
next: TResult,
registration: PluginHookRegistration<K>,
) => TResult;
mergeNullResults?: boolean;
shouldStop?: (result: TResult) => boolean;
terminalLabel?: string;
onTerminal?: (params: { hookName: K; pluginId: string; result: TResult }) => void;
@@ -252,7 +261,10 @@ export function createHookRunner(
) {
const logger = options.logger;
const catchErrors = options.catchErrors ?? true;
const failurePolicyByHook = options.failurePolicyByHook ?? {};
const failurePolicyByHook = {
before_agent_run: "fail-closed",
...options.failurePolicyByHook,
} satisfies Partial<Record<PluginHookName, HookFailurePolicy>>;
const voidHookTimeoutMsByHook = {
...DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK,
...options.voidHookTimeoutMsByHook,
@@ -577,7 +589,9 @@ export function createHookRunner(
const timeoutMs = getModifyingHookTimeoutMs(hookName, hook);
const handlerResult = timeoutMs ? await withHookTimeout(promise, timeoutMs) : await promise;
if (handlerResult !== undefined && handlerResult !== null) {
const shouldMergeResult =
handlerResult !== undefined && (handlerResult !== null || policy.mergeNullResults);
if (shouldMergeResult) {
if (policy.mergeResults) {
result = policy.mergeResults(result, handlerResult, hook);
} else {
@@ -1050,7 +1064,57 @@ export function createHookRunner(
return runVoidHook("message_sent", event, ctx);
}
// =========================================================================
/**
* Run before_agent_run gate hook.
* Fires after session resolution and workspace preparation, before model inference.
* Returns the most-restrictive pass/block decision from all handlers.
* Handlers that return void are treated as pass.
*/
async function runBeforeAgentRun(
event: PluginHookBeforeAgentRunEvent,
ctx: PluginHookAgentContext,
): Promise<GateHookResult<InputGateDecision> | undefined> {
let winningPluginId: string | undefined;
const decision = await runModifyingHook<"before_agent_run", InputGateDecision | undefined>(
"before_agent_run",
event,
ctx,
{
mergeResults: (_acc, next, reg) => {
if (next === undefined || next === null) {
const normalized: InputGateDecision = {
outcome: "block",
reason: "before_agent_run returned an invalid decision",
};
winningPluginId = reg.pluginId;
return normalized;
}
const normalized: InputGateDecision = isHookDecision(next)
? next
: {
outcome: "block",
reason: "before_agent_run returned an invalid decision",
};
const merged =
!_acc || (normalized.outcome === "block" && _acc.outcome !== "block")
? normalized
: _acc;
if (merged === normalized) {
winningPluginId = reg.pluginId;
}
return merged;
},
mergeNullResults: true,
shouldStop: (result) => result?.outcome === "block",
terminalLabel: "gate-decision",
},
);
if (!decision) {
return undefined;
}
return { decision, pluginId: winningPluginId ?? "unknown" };
}
// Tool Hooks
// =========================================================================
@@ -1396,9 +1460,6 @@ export function createHookRunner(
// Utility
// =========================================================================
/**
* Check if any hooks are registered for a given hook name.
*/
function hasHooks(hookName: PluginHookName): boolean {
return registry.typedHooks.some((h) => h.hookName === hookName);
}
@@ -1426,6 +1487,8 @@ export function createHookRunner(
runBeforeCompaction,
runAfterCompaction,
runBeforeReset,
// Lifecycle gate hooks
runBeforeAgentRun,
// Message hooks
runInboundClaim,
runInboundClaimForPlugin,

View File

@@ -5350,6 +5350,7 @@ module.exports = {
"hook-policy": {
hooks: {
allowPromptInjection: false,
allowConversationAccess: true,
},
},
},
@@ -5465,6 +5466,7 @@ module.exports = {
entries: {
"hook-timeouts": {
hooks: {
allowConversationAccess: true,
timeoutMs: 250,
timeouts: {
before_model_resolve: 750,
@@ -5490,10 +5492,13 @@ module.exports = {
id: "conversation-hooks",
filename: "conversation-hooks.cjs",
body: `module.exports = { id: "conversation-hooks", register(api) {
api.on("before_model_resolve", () => undefined);
api.on("before_agent_reply", () => undefined);
api.on("llm_input", () => undefined);
api.on("llm_output", () => undefined);
api.on("before_agent_finalize", () => undefined);
api.on("agent_end", () => undefined);
api.on("before_agent_run", () => undefined);
} };`,
});
@@ -5510,7 +5515,7 @@ module.exports = {
"non-bundled plugins must set plugins.entries.conversation-hooks.hooks.allowConversationAccess=true",
),
);
expect(blockedDiagnostics).toHaveLength(4);
expect(blockedDiagnostics).toHaveLength(7);
});
it("allows conversation typed hooks for non-bundled plugins when explicitly enabled", () => {
@@ -5519,10 +5524,13 @@ module.exports = {
id: "conversation-hooks-allowed",
filename: "conversation-hooks-allowed.cjs",
body: `module.exports = { id: "conversation-hooks-allowed", register(api) {
api.on("before_model_resolve", () => undefined);
api.on("before_agent_reply", () => undefined);
api.on("llm_input", () => undefined);
api.on("llm_output", () => undefined);
api.on("before_agent_finalize", () => undefined);
api.on("agent_end", () => undefined);
api.on("before_agent_run", () => undefined);
} };`,
});
@@ -5541,10 +5549,13 @@ module.exports = {
});
expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual([
"before_model_resolve",
"before_agent_reply",
"llm_input",
"llm_output",
"before_agent_finalize",
"agent_end",
"before_agent_run",
]);
});
@@ -5564,6 +5575,13 @@ module.exports = {
plugin,
pluginConfig: {
allow: ["hook-unknown"],
entries: {
"hook-unknown": {
hooks: {
allowConversationAccess: true,
},
},
},
},
});