mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 07:40:42 +00:00
Summary: - The PR adds a `before_agent_run` plugin hook with pass/block decisions, redacted blocked-turn persistence, diagnostics/docs/changelog updates, and focused runner, gateway, session, and plugin tests. - Reproducibility: not applicable. as a feature PR rather than a current-main bug report. Current main lacks ` ... un`, while the PR head adds source coverage and copied live Gateway/WebChat log proof for the new behavior. Automerge notes: - PR branch already contained follow-up commit before automerge: fix: trim before agent hook PR scope - PR branch already contained follow-up commit before automerge: fix: keep before-agent blocks redacted - PR branch already contained follow-up commit before automerge: fix: keep runtime context out of model prompt - PR branch already contained follow-up commit before automerge: docs: refresh config baseline after rebase - PR branch already contained follow-up commit before automerge: fix: align blocked turn clients with redacted content - PR branch already contained follow-up commit before automerge: fix: remove out-of-scope client block UI changes Validation: - ClawSweeper review passed for head767e46fde8. - Required merge gates passed before the squash merge. Prepared head SHA:767e46fde8Review: https://github.com/openclaw/openclaw/pull/75035#issuecomment-4351843275 Co-authored-by: Jesse Merhi <jessejmerhi@gmail.com> Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
114 lines
3.6 KiB
TypeScript
114 lines
3.6 KiB
TypeScript
/**
|
|
* Structured decision returned by gate/policy hooks.
|
|
* Core is outcome-agnostic — it handles the mechanics of each outcome
|
|
* without knowing *why* the decision was made.
|
|
*/
|
|
export type HookDecision = HookDecisionPass | HookDecisionBlock;
|
|
|
|
/** Content is fine. Proceed normally. */
|
|
export type HookDecisionPass = {
|
|
outcome: "pass";
|
|
};
|
|
|
|
/** Prefix for user-facing replacement messages when a `block` decision stops a request. */
|
|
export const BLOCK_MESSAGE_PREFIX = "Your message could not be sent";
|
|
|
|
/**
|
|
* Content is blocked. `reason` is internal plugin-local detail; core must not log,
|
|
* persist, broadcast, or expose it verbatim. `message` is user-facing detail.
|
|
*/
|
|
export type HookDecisionBlock = {
|
|
outcome: "block";
|
|
/** Internal plugin-local reason. Do not log, persist, broadcast, or expose verbatim. */
|
|
reason: string;
|
|
/** Optional user-facing detail included in the block response envelope. */
|
|
message?: string;
|
|
/** Plugin-defined category for analytics (e.g. "violence", "pii", "cost_limit"). */
|
|
category?: string;
|
|
/** Opaque metadata for the plugin's own use. Core does not interpret it. */
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
|
|
export function resolveBlockMessage(
|
|
decision: HookDecisionBlock,
|
|
params: { blockedBy?: string } = {},
|
|
): string {
|
|
const message = typeof decision.message === "string" ? decision.message.trim() : "";
|
|
const blockedBy = params.blockedBy?.trim();
|
|
if (message) {
|
|
return blockedBy
|
|
? `${BLOCK_MESSAGE_PREFIX}: ${message} (blocked by ${blockedBy})`
|
|
: `${BLOCK_MESSAGE_PREFIX}: ${message}`;
|
|
}
|
|
return blockedBy
|
|
? `${BLOCK_MESSAGE_PREFIX}: blocked by ${blockedBy}`
|
|
: `${BLOCK_MESSAGE_PREFIX}: blocked`;
|
|
}
|
|
|
|
/** Outcome severity for most-restrictive-wins merging. Higher = more restrictive. */
|
|
export const HOOK_DECISION_SEVERITY: Record<HookDecision["outcome"], number> = {
|
|
pass: 0,
|
|
block: 2,
|
|
};
|
|
|
|
/**
|
|
* Merge two HookDecisions using most-restrictive-wins semantics.
|
|
* `block > pass`
|
|
*/
|
|
export function mergeHookDecisions(a: HookDecision | undefined, b: HookDecision): HookDecision {
|
|
if (!a) {
|
|
return b;
|
|
}
|
|
return HOOK_DECISION_SEVERITY[b.outcome] > HOOK_DECISION_SEVERITY[a.outcome] ? b : a;
|
|
}
|
|
|
|
/**
|
|
* Type guard: does this object look like a HookDecision (has `outcome` field)?
|
|
*/
|
|
export function isHookDecision(value: unknown): value is HookDecision {
|
|
if (typeof value !== "object" || value === null) {
|
|
return false;
|
|
}
|
|
const v = value as Record<string, unknown>;
|
|
const keys = Object.keys(v);
|
|
if (v.outcome === "pass") {
|
|
return keys.length === 1;
|
|
}
|
|
if (v.outcome !== "block") {
|
|
return false;
|
|
}
|
|
const allowedBlockKeys = new Set(["outcome", "reason", "message", "category", "metadata"]);
|
|
if (keys.some((key) => !allowedBlockKeys.has(key))) {
|
|
return false;
|
|
}
|
|
if (typeof v.reason !== "string" || !v.reason.trim()) {
|
|
return false;
|
|
}
|
|
if ("message" in v && (typeof v.message !== "string" || !v.message.trim())) {
|
|
return false;
|
|
}
|
|
if ("category" in v && (typeof v.category !== "string" || !v.category.trim())) {
|
|
return false;
|
|
}
|
|
if (
|
|
"metadata" in v &&
|
|
(typeof v.metadata !== "object" || v.metadata === null || Array.isArray(v.metadata))
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Outcomes valid for input gates (before_agent_run). */
|
|
export type InputGateDecision = HookDecisionPass | HookDecisionBlock;
|
|
|
|
/**
|
|
* A gate hook decision paired with the pluginId that produced it.
|
|
* Returned by gate hook runners so callers can
|
|
* attribute blocked entries and audit events to the originating plugin.
|
|
*/
|
|
export type GateHookResult<TDecision extends HookDecision = HookDecision> = {
|
|
decision: TDecision;
|
|
pluginId: string;
|
|
};
|