Files
openclaw/src/plugins/trusted-tool-policy.ts
Peter Steinberger e4bae42d63 feat(plugin-sdk): derive tool target paths for hooks
Summary:
- derive apply_patch target paths for before_tool_call and trusted policy events
- route native Codex PreToolUse cwd/sandbox path facts through the host parser
- document the additive derivedPaths hook field and refresh the SDK API baseline

Verification:
- pnpm test src/agents/apply-patch-paths.test.ts src/plugins/host-tool-param-parsers.test.ts src/agents/pi-tools.before-tool-call.e2e.test.ts src/agents/harness/native-hook-relay.test.ts src/plugins/contracts/host-hooks.contract.test.ts
- pnpm check:test-types
- pnpm lint:core
- pnpm plugin-sdk:api:gen
- pnpm plugin-sdk:api:check
- pnpm run check:no-conflict-markers
- pnpm exec oxfmt --check --threads=1 CHANGELOG.md docs/plugins/hooks.md docs/.generated/plugin-sdk-api-baseline.sha256 src/agents/apply-patch-paths.test.ts src/agents/apply-patch-paths.ts src/agents/harness/native-hook-relay.test.ts src/agents/harness/native-hook-relay.ts src/agents/pi-tools.before-tool-call.e2e.test.ts src/agents/pi-tools.before-tool-call.ts src/agents/pi-tools.ts src/auto-reply/reply/dispatch-from-config.test.ts src/plugins/contracts/host-hooks.contract.test.ts src/plugins/hook-types.ts src/plugins/host-tool-param-parsers.test.ts src/plugins/host-tool-param-parsers.ts src/plugins/trusted-tool-policy.ts
- git diff --check origin/main...HEAD && git diff --check
- pnpm build

Co-authored-by: Eva <eva@100yen.org>
Co-authored-by: Josh Lehman <josh@martian.engineering>
2026-05-09 03:31:42 -04:00

128 lines
4.9 KiB
TypeScript

import { getRuntimeConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isPlainObject } from "../utils.js";
import type {
PluginHookBeforeToolCallEvent,
PluginHookBeforeToolCallResult,
PluginHookToolContext,
} from "./hook-types.js";
import { getPluginSessionExtensionStateSync } from "./host-hook-state.js";
import type { PluginJsonValue } from "./host-hooks.js";
import { getActivePluginRegistry } from "./runtime.js";
export function hasTrustedToolPolicies(): boolean {
return (getActivePluginRegistry()?.trustedToolPolicies?.length ?? 0) > 0;
}
function normalizeDerivedEventFields(
value: Pick<PluginHookBeforeToolCallEvent, "derivedPaths"> | undefined,
): Pick<PluginHookBeforeToolCallEvent, "derivedPaths"> {
return Array.isArray(value?.derivedPaths)
? { derivedPaths: Object.freeze([...value.derivedPaths]) }
: {};
}
export async function runTrustedToolPolicies(
event: PluginHookBeforeToolCallEvent,
ctx: PluginHookToolContext,
options?: {
config?: OpenClawConfig;
deriveEvent?: (
params: Record<string, unknown>,
) => Pick<PluginHookBeforeToolCallEvent, "derivedPaths">;
},
): Promise<PluginHookBeforeToolCallResult | undefined> {
const policies = getActivePluginRegistry()?.trustedToolPolicies ?? [];
let adjustedParams = event.params;
let hasAdjustedParams = false;
let approval: PluginHookBeforeToolCallResult["requireApproval"];
const sessionExtensionStateCache = new Map<string, Record<string, PluginJsonValue> | undefined>();
let resolvedSessionConfig: OpenClawConfig | undefined = options?.config;
let didResolveSessionConfig = Boolean(options?.config);
const resolveSessionConfig = (): OpenClawConfig | undefined => {
if (!didResolveSessionConfig) {
didResolveSessionConfig = true;
try {
resolvedSessionConfig = getRuntimeConfig();
} catch {
resolvedSessionConfig = undefined;
}
}
return resolvedSessionConfig;
};
const { derivedPaths, ...eventWithoutDerivedPaths } = event;
let currentDerivedEvent = normalizeDerivedEventFields({ derivedPaths });
const buildEvent = (): PluginHookBeforeToolCallEvent => {
return {
...eventWithoutDerivedPaths,
params: adjustedParams,
...currentDerivedEvent,
};
};
for (const registration of policies) {
const policyCtx: PluginHookToolContext = {
...ctx,
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Plugin callers type JSON reads by namespace.
getSessionExtension: <T extends PluginJsonValue = PluginJsonValue>(namespace: string) => {
const normalizedNamespace = namespace.trim();
const cacheKey = registration.pluginId;
if (!sessionExtensionStateCache.has(cacheKey)) {
const config = ctx.sessionKey ? resolveSessionConfig() : undefined;
sessionExtensionStateCache.set(
cacheKey,
config
? getPluginSessionExtensionStateSync({
cfg: config,
pluginId: registration.pluginId,
sessionKey: ctx.sessionKey,
})
: undefined,
);
}
const pluginState = sessionExtensionStateCache.get(cacheKey);
if (!normalizedNamespace || !pluginState) {
return undefined;
}
return pluginState[normalizedNamespace] as T | undefined;
},
};
const decision = await registration.policy.evaluate(buildEvent(), policyCtx);
if (!decision) {
continue;
}
if ("allow" in decision && decision.allow === false) {
return {
block: true,
blockReason: decision.reason ?? `blocked by ${registration.policy.id}`,
};
}
// `block: true` is terminal; normalize a missing blockReason to a deterministic
// reason so downstream diagnostics match the `{ allow: false }` path above.
if ("block" in decision && decision.block === true) {
return {
...decision,
blockReason: decision.blockReason ?? `blocked by ${registration.policy.id}`,
};
}
// `block: false` is a no-op (matches the regular `before_tool_call` hook
// pipeline) — it does NOT short-circuit the policy chain. Params and
// approvals are remembered so later trusted policies can still inspect or
// block the final call.
if ("params" in decision && isPlainObject(decision.params)) {
adjustedParams = decision.params;
hasAdjustedParams = true;
currentDerivedEvent = normalizeDerivedEventFields(options?.deriveEvent?.(adjustedParams));
}
if ("requireApproval" in decision && decision.requireApproval && !approval) {
approval = decision.requireApproval;
}
}
if (!hasAdjustedParams && !approval) {
return undefined;
}
return {
...(hasAdjustedParams ? { params: adjustedParams } : {}),
...(approval ? { requireApproval: approval } : {}),
};
}