diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b876fdc2f..3566f34dbcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index a604b62cd35..b50dae930ce 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index f9b6788143e..82849d5620d 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 0529d8fb509..8d79f2ac78b 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -275,7 +275,7 @@ For runtime hook debugging: - `openclaw plugins inspect --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..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..hooks.allowConversationAccess=true`. Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`): diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ab11b45c76e..45c45069d7e 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -195,7 +195,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and - `plugins.entries..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. - `plugins.entries..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..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..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..subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs. - `plugins.entries..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..config`: plugin-defined config object (validated by native OpenClaw plugin schema when available). diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index bae78123ccf..f72d21ea65c 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -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 { diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 417f4772735..b0144d51b7f 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -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 --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..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 diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index d77783a899c..46cd0d628cc 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -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(); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 7a6bc84f062..c22bc53a5f5 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -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"); } diff --git a/extensions/diagnostics-prometheus/src/service.test.ts b/extensions/diagnostics-prometheus/src/service.test.ts index 1bd10ddee1c..85a67c1a81f 100644 --- a/extensions/diagnostics-prometheus/src/service.test.ts +++ b/extensions/diagnostics-prometheus/src/service.test.ts @@ -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(); diff --git a/extensions/diagnostics-prometheus/src/service.ts b/extensions/diagnostics-prometheus/src/service.ts index 1fb75e171d7..38d341500e5 100644 --- a/extensions/diagnostics-prometheus/src/service.ts +++ b/extensions/diagnostics-prometheus/src/service.ts @@ -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"), diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts index 25b1501fc31..b33f5f7cdb2 100644 --- a/src/agents/cli-runner.reliability.test.ts +++ b/src/agents/cli-runner.reliability.test.ts @@ -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"), diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 99a496b2f11..0c9b3a69ae8 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -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 => { + 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[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["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, diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 1148cbb3fb3..8cd6b567df1 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -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: [], diff --git a/src/agents/pi-embedded-runner/result-fallback-classifier.ts b/src/agents/pi-embedded-runner/result-fallback-classifier.ts index 27742d3362b..124787dc213 100644 --- a/src/agents/pi-embedded-runner/result-fallback-classifier.ts +++ b/src/agents/pi-embedded-runner/result-fallback-classifier.ts @@ -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), ); diff --git a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts index b9e413e0f83..4c600a29fcd 100644 --- a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts +++ b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts @@ -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( diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index e1f79574521..b4880591cb5 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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. diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index c74c060608c..ad5f59f6614 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -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[0], + ) as Array>; + + 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 { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index ecce4cb8638..8192bcfa255 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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): 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 => { + 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[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["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); diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index 621b3a02a8b..0fced82fc4d 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -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; diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index 3b8401436ab..a669ccda869 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -140,7 +140,8 @@ export type EmbeddedPiRunMeta = { | "compaction_failure" | "role_ordering" | "image_size" - | "retry_limit"; + | "retry_limit" + | "hook_block"; message: string; }; failureSignal?: EmbeddedRunFailureSignal; diff --git a/src/agents/pi-tools.before-tool-call.e2e.test.ts b/src/agents/pi-tools.before-tool-call.e2e.test.ts index 7d95d1ba5f4..e2f772ee9b7 100644 --- a/src/agents/pi-tools.before-tool-call.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.e2e.test.ts @@ -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(() => {})); diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index d810e676a1a..54ec0f05241 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -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( { diff --git a/src/auto-reply/reply-payload.ts b/src/auto-reply/reply-payload.ts index 18778d6d819..1ae9f9a749a 100644 --- a/src/auto-reply/reply-payload.ts +++ b/src/auto-reply/reply-payload.ts @@ -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(); diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index c1d0ba7cfd6..04f0bba35c0 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -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" }], diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 160fd18cde1..f2c70d2867d 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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, diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index d9079ea8f94..e99617a35ce 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -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 = { diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index cd6831cb13c..b6da85e9ed4 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -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) { diff --git a/src/auto-reply/reply/dispatch-from-config.types.ts b/src/auto-reply/reply/dispatch-from-config.types.ts index 91e9f68d95f..1683b33ca98 100644 --- a/src/auto-reply/reply/dispatch-from-config.types.ts +++ b/src/auto-reply/reply/dispatch-from-config.types.ts @@ -10,6 +10,7 @@ export type DispatchFromConfigResult = { counts: Record; failedCounts?: Partial>; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + beforeAgentRunBlocked?: boolean; }; export type DispatchFromConfigParams = { diff --git a/src/auto-reply/reply/source-reply-delivery-mode.test.ts b/src/auto-reply/reply/source-reply-delivery-mode.test.ts index eadae391a54..47b072805fa 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.test.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.test.ts @@ -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({ diff --git a/src/auto-reply/reply/source-reply-delivery-mode.ts b/src/auto-reply/reply/source-reply-delivery-mode.ts index fbd45055e5b..90e5574b3ce 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.ts @@ -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); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index ba24a6faa4d..f26f2d04d6f 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1242,7 +1242,7 @@ export const FIELD_HELP: Record = { "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": diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 6025a11b44f..fade95eccad 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -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; diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index a1d076ee1a4..75440f71db8 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -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, 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"; diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index e04cd5b7a6b..612ef5cb8e1 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -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 | 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)}`, ); diff --git a/src/gateway/session-history-state.ts b/src/gateway/session-history-state.ts index 5673bcb9ad7..6a9c070288d 100644 --- a/src/gateway/session-history-state.ts +++ b/src/gateway/session-history-state.ts @@ -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, diff --git a/src/gateway/session-message-events.test.ts b/src/gateway/session-message-events.test.ts index a7f13056e80..f3bce878c28 100644 --- a/src/gateway/session-message-events.test.ts +++ b/src/gateway/session-message-events.test.ts @@ -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({ diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 53f5013e0ca..12faf732f78 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -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[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", () => { diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 5db2edba50a..6c0525c1eac 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -360,8 +360,10 @@ function selectBoundedActiveTailRecords(entries: TailTranscriptRecord[]): TailTr const byId = new Map(); 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[] { diff --git a/src/gateway/sessions-history-http.revocation.test.ts b/src/gateway/sessions-history-http.revocation.test.ts index 5b745c0fd65..5598c26cd72 100644 --- a/src/gateway/sessions-history-http.revocation.test.ts +++ b/src/gateway/sessions-history-http.revocation.test.ts @@ -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, diff --git a/src/infra/diagnostic-events.ts b/src/infra/diagnostic-events.ts index 5653525244f..b809b1e6bbd 100644 --- a/src/infra/diagnostic-events.ts +++ b/src/infra/diagnostic-events.ts @@ -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"; diff --git a/src/plugins/hook-decision-types.test.ts b/src/plugins/hook-decision-types.test.ts new file mode 100644 index 00000000000..f9c4b670478 --- /dev/null +++ b/src/plugins/hook-decision-types.test.ts @@ -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`, + ); + }); + }); +}); diff --git a/src/plugins/hook-decision-types.ts b/src/plugins/hook-decision-types.ts new file mode 100644 index 00000000000..08a5fda7d4f --- /dev/null +++ b/src/plugins/hook-decision-types.ts @@ -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; +}; + +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 = { + 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; + 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 = { + decision: TDecision; + pluginId: string; +}; diff --git a/src/plugins/hook-lifecycle-gates.test.ts b/src/plugins/hook-lifecycle-gates.test.ts new file mode 100644 index 00000000000..7e352145f1a --- /dev/null +++ b/src/plugins/hook-lifecycle-gates.test.ts @@ -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(() => {}), + 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; + 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"); + }); +}); diff --git a/src/plugins/hook-runner-global.ts b/src/plugins/hook-runner-global.ts index a25af87c09a..5c5c0a75b94 100644 --- a/src/plugins/hook-runner-global.ts +++ b/src/plugins/hook-runner-global.ts @@ -41,6 +41,7 @@ export function initializeGlobalHookRunner(registry: GlobalHookRunnerRegistry): }, catchErrors: true, failurePolicyByHook: { + before_agent_run: "fail-closed", before_tool_call: "fail-closed", }, }); diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 31509a71e59..d3e7d4935e6 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -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; @@ -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?: ( 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; }; @@ -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; + before_agent_run: ( + event: PluginHookBeforeAgentRunEvent, + ctx: PluginHookAgentContext, + ) => Promise | PluginHookBeforeAgentRunResult; }; export type PluginHookRegistration = { diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index fe3035e08ab..2444f1eae92 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -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> = { + before_agent_run: 15_000, before_prompt_build: 15_000, }; @@ -193,6 +201,7 @@ type ModifyingHookPolicy = { next: TResult, registration: PluginHookRegistration, ) => 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>; 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 | 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, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index b92c3a6814f..4350363a26a 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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, + }, + }, + }, }, });