feat(amazon-bedrock): add Bedrock Guardrails support (#58588)

* feat(amazon-bedrock): just the kiro plans, need to remove before PR

* docs(bedrock-guardrails): add environment setup instructions

* docs(bedrock-guardrails): mark environment setup tasks as completed

* feat(amazon-bedrock): add trace configuration to guardrail settings

* feat(amazon-bedrock): implement guardrail wrapper factory and wire into registration

* test(amazon-bedrock): add comprehensive guardrail configuration tests

* docs(bedrock): add guardrails configuration documentation

* docs(bedrock-guardrails): add comprehensive manual testing guide for Docker deployment

* docs(bedrock-guardrails): expand manual testing guide with STS credentials and config options

* docs(bedrock-guardrails): complete manual testing verification with 8 test scenarios

* chore: remove kiro spec files from PR

* fix(docs): correct guardrail config path to plugins.entries.*.config

* style: format docs and test files
This commit is contained in:
Michael Flanagan 2026-03-31 20:09:52 -05:00 committed by GitHub
parent 40b24dfa6b
commit eee185af99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 308 additions and 3 deletions

View File

@ -174,3 +174,44 @@ openclaw models list
current capabilities.
- If you prefer a managed key flow, you can also place an OpenAIcompatible
proxy in front of Bedrock and configure it as an OpenAI provider instead.
## Guardrails
You can apply [Amazon Bedrock Guardrails](https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html)
to all Bedrock model invocations by adding a `guardrail` object to the
`amazon-bedrock` plugin config. Guardrails let you enforce content filtering,
topic denial, word filters, sensitive information filters, and contextual
grounding checks.
```json5
{
plugins: {
entries: {
"amazon-bedrock": {
config: {
guardrail: {
guardrailIdentifier: "abc123", // guardrail ID or full ARN
guardrailVersion: "1", // version number or "DRAFT"
streamProcessingMode: "sync", // optional: "sync" or "async"
trace: "enabled", // optional: "enabled", "disabled", or "enabled_full"
},
},
},
},
},
}
```
- `guardrailIdentifier` (required) accepts a guardrail ID (e.g. `abc123`) or a
full ARN (e.g. `arn:aws:bedrock:us-east-1:123456789012:guardrail/abc123`).
- `guardrailVersion` (required) specifies which published version to use, or
`"DRAFT"` for the working draft.
- `streamProcessingMode` (optional) controls whether guardrail evaluation runs
synchronously (`"sync"`) or asynchronously (`"async"`) during streaming. If
omitted, Bedrock uses its default behavior.
- `trace` (optional) enables guardrail trace output in the API response. Set to
`"enabled"` or `"enabled_full"` for debugging; omit or set `"disabled"` for
production.
The IAM principal used by the gateway must have the `bedrock:ApplyGuardrail`
permission in addition to the standard invoke permissions.

View File

@ -1,7 +1,91 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../src/config/config.js";
import { buildPluginApi } from "../../src/plugins/api-builder.js";
import type { PluginRuntime } from "../../src/plugins/runtime/types.js";
import type { ProviderPlugin } from "../../src/plugins/types.js";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import amazonBedrockPlugin from "./index.js";
/** Register the amazon-bedrock plugin with an optional pluginConfig override. */
function registerWithConfig(pluginConfig?: Record<string, unknown>): ProviderPlugin {
const providers: ProviderPlugin[] = [];
const noopLogger = { info() {}, warn() {}, error() {}, debug() {} };
const api = buildPluginApi({
id: "amazon-bedrock",
name: "Amazon Bedrock Provider",
source: "test",
registrationMode: "full",
config: {} as OpenClawConfig,
pluginConfig,
runtime: {} as PluginRuntime,
logger: noopLogger,
resolvePath: (input) => input,
handlers: {
registerProvider(provider: ProviderPlugin) {
providers.push(provider);
},
},
});
amazonBedrockPlugin.register(api);
const provider = providers[0];
if (!provider) throw new Error("provider registration missing");
return provider;
}
/** Spy streamFn that returns the options it receives. */
const spyStreamFn = (_model: unknown, _context: unknown, options: Record<string, unknown>) =>
options;
const ANTHROPIC_MODEL = "us.anthropic.claude-sonnet-4-6-v1";
const NON_ANTHROPIC_MODEL = "amazon.nova-micro-v1:0";
const MODEL_DESCRIPTOR = {
api: "openai-completions",
provider: "amazon-bedrock",
id: NON_ANTHROPIC_MODEL,
} as never;
const ANTHROPIC_MODEL_DESCRIPTOR = {
api: "openai-completions",
provider: "amazon-bedrock",
id: ANTHROPIC_MODEL,
} as never;
/**
* Call wrapStreamFn and then invoke the returned stream function, capturing
* the payload via the onPayload hook that streamWithPayloadPatch installs.
*/
function callWrappedStream(
provider: ProviderPlugin,
modelId: string,
modelDescriptor: never,
): Record<string, unknown> {
const wrapped = provider.wrapStreamFn?.({
provider: "amazon-bedrock",
modelId,
streamFn: spyStreamFn,
} as never);
// The wrapped stream returns the options object (from spyStreamFn).
// For guardrail-wrapped streams, streamWithPayloadPatch intercepts onPayload,
// so we need to invoke onPayload on the returned options to trigger the patch.
const result = wrapped?.(modelDescriptor, { messages: [] } as never, {}) as unknown as Record<
string,
unknown
>;
// If onPayload was installed by streamWithPayloadPatch, call it to apply the patch.
if (typeof result?.onPayload === "function") {
const payload: Record<string, unknown> = {};
(result.onPayload as (p: Record<string, unknown>) => void)(payload);
return { ...result, _capturedPayload: payload };
}
return result;
}
describe("amazon-bedrock provider plugin", () => {
it("marks Claude 4.6 Bedrock models as adaptive by default", () => {
const provider = registerSingleProviderPlugin(amazonBedrockPlugin);
@ -42,4 +126,127 @@ describe("amazon-bedrock provider plugin", () => {
cacheRetention: "none",
});
});
describe("guardrail config schema", () => {
it("defines guardrail object with correct property types, required fields, and enums", () => {
const pluginJson = JSON.parse(
readFileSync(resolve(import.meta.dirname, "openclaw.plugin.json"), "utf-8"),
);
const guardrail = pluginJson.configSchema?.properties?.guardrail;
expect(guardrail).toBeDefined();
expect(guardrail.type).toBe("object");
expect(guardrail.additionalProperties).toBe(false);
// Required fields
expect(guardrail.required).toEqual(["guardrailIdentifier", "guardrailVersion"]);
// Property types
expect(guardrail.properties.guardrailIdentifier).toEqual({ type: "string" });
expect(guardrail.properties.guardrailVersion).toEqual({ type: "string" });
// Enum constraints
expect(guardrail.properties.streamProcessingMode).toEqual({
type: "string",
enum: ["sync", "async"],
});
expect(guardrail.properties.trace).toEqual({
type: "string",
enum: ["enabled", "disabled", "enabled_full"],
});
});
});
describe("guardrail payload injection", () => {
it("does not inject guardrailConfig when guardrail is absent from plugin config", () => {
const provider = registerWithConfig(undefined);
const result = callWrappedStream(provider, NON_ANTHROPIC_MODEL, MODEL_DESCRIPTOR);
expect(result).not.toHaveProperty("_capturedPayload");
// The onPayload hook should not exist when no guardrail is configured
expect(result).toMatchObject({ cacheRetention: "none" });
});
it("injects all four fields when guardrail config includes optional fields", () => {
const provider = registerWithConfig({
guardrail: {
guardrailIdentifier: "my-guardrail-id",
guardrailVersion: "1",
streamProcessingMode: "sync",
trace: "enabled",
},
});
const result = callWrappedStream(provider, NON_ANTHROPIC_MODEL, MODEL_DESCRIPTOR);
expect(result._capturedPayload).toEqual({
guardrailConfig: {
guardrailIdentifier: "my-guardrail-id",
guardrailVersion: "1",
streamProcessingMode: "sync",
trace: "enabled",
},
});
});
it("injects only required fields when optional fields are omitted", () => {
const provider = registerWithConfig({
guardrail: {
guardrailIdentifier: "abc123",
guardrailVersion: "DRAFT",
},
});
const result = callWrappedStream(provider, NON_ANTHROPIC_MODEL, MODEL_DESCRIPTOR);
expect(result._capturedPayload).toEqual({
guardrailConfig: {
guardrailIdentifier: "abc123",
guardrailVersion: "DRAFT",
},
});
});
it("injects guardrailConfig for Anthropic models without cacheRetention: none", () => {
const provider = registerWithConfig({
guardrail: {
guardrailIdentifier: "guardrail-anthropic",
guardrailVersion: "2",
streamProcessingMode: "async",
trace: "disabled",
},
});
const result = callWrappedStream(provider, ANTHROPIC_MODEL, ANTHROPIC_MODEL_DESCRIPTOR);
// Anthropic models should get guardrailConfig
expect(result._capturedPayload).toEqual({
guardrailConfig: {
guardrailIdentifier: "guardrail-anthropic",
guardrailVersion: "2",
streamProcessingMode: "async",
trace: "disabled",
},
});
// Anthropic models should NOT get cacheRetention: "none"
expect(result).not.toHaveProperty("cacheRetention", "none");
});
it("injects guardrailConfig for non-Anthropic models with cacheRetention: none", () => {
const provider = registerWithConfig({
guardrail: {
guardrailIdentifier: "guardrail-nova",
guardrailVersion: "3",
},
});
const result = callWrappedStream(provider, NON_ANTHROPIC_MODEL, MODEL_DESCRIPTOR);
// Non-Anthropic models should get guardrailConfig
expect(result._capturedPayload).toEqual({
guardrailConfig: {
guardrailIdentifier: "guardrail-nova",
guardrailVersion: "3",
},
});
// Non-Anthropic models should also get cacheRetention: "none"
expect(result).toMatchObject({ cacheRetention: "none" });
});
});
});

View File

@ -1,7 +1,9 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import {
createBedrockNoCacheWrapper,
isAnthropicBedrockModel,
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream";
import {
mergeImplicitBedrockProvider,
@ -9,6 +11,38 @@ import {
resolveImplicitBedrockProvider,
} from "./api.js";
type GuardrailConfig = {
guardrailIdentifier: string;
guardrailVersion: string;
streamProcessingMode?: "sync" | "async";
trace?: "enabled" | "disabled" | "enabled_full";
};
function createGuardrailWrapStreamFn(
innerWrapStreamFn: (ctx: { modelId: string; streamFn?: StreamFn }) => StreamFn | null | undefined,
guardrailConfig: GuardrailConfig,
): (ctx: { modelId: string; streamFn?: StreamFn }) => StreamFn | null | undefined {
return (ctx) => {
const inner = innerWrapStreamFn(ctx);
if (!inner) return inner;
return (model, context, options) => {
return streamWithPayloadPatch(inner, model, context, options, (payload) => {
const gc: Record<string, unknown> = {
guardrailIdentifier: guardrailConfig.guardrailIdentifier,
guardrailVersion: guardrailConfig.guardrailVersion,
};
if (guardrailConfig.streamProcessingMode) {
gc.streamProcessingMode = guardrailConfig.streamProcessingMode;
}
if (guardrailConfig.trace) {
gc.trace = guardrailConfig.trace;
}
payload.guardrailConfig = gc;
});
};
};
}
const PROVIDER_ID = "amazon-bedrock";
const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
@ -17,6 +51,18 @@ export default definePluginEntry({
name: "Amazon Bedrock Provider",
description: "Bundled Amazon Bedrock provider policy plugin",
register(api) {
const guardrail = (api.pluginConfig as Record<string, unknown> | undefined)?.guardrail as
| GuardrailConfig
| undefined;
const baseWrapStreamFn = ({ modelId, streamFn }: { modelId: string; streamFn?: StreamFn }) =>
isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn);
const wrapStreamFn =
guardrail?.guardrailIdentifier && guardrail?.guardrailVersion
? createGuardrailWrapStreamFn(baseWrapStreamFn, guardrail)
: baseWrapStreamFn;
api.registerProvider({
id: PROVIDER_ID,
label: "Amazon Bedrock",
@ -45,8 +91,7 @@ export default definePluginEntry({
providerFamily: "anthropic",
dropThinkingBlockModelHints: ["claude"],
},
wrapStreamFn: ({ modelId, streamFn }) =>
isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn),
wrapStreamFn,
resolveDefaultThinkingLevel: ({ modelId }) =>
CLAUDE_46_MODEL_RE.test(modelId.trim()) ? "adaptive" : undefined,
});

View File

@ -5,6 +5,18 @@
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"guardrail": {
"type": "object",
"additionalProperties": false,
"properties": {
"guardrailIdentifier": { "type": "string" },
"guardrailVersion": { "type": "string" },
"streamProcessingMode": { "type": "string", "enum": ["sync", "async"] },
"trace": { "type": "string", "enum": ["enabled", "disabled", "enabled_full"] }
},
"required": ["guardrailIdentifier", "guardrailVersion"]
}
}
}
}