Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 66 additions & 13 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ lz4_flex = "0.11"
# (api7/aisix#860).
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "rustls-tls-native-roots", "gzip", "multipart"] }

# kind=custom guardrail: the sandboxed engine that runs operator-supplied
# screening scripts. Vendors quickjs-ng; `futures` makes Rust futures
# awaitable from the script (that is how `fetch` works) and `parallel`
# makes the runtime Send so a hook can run on any worker thread.
rquickjs = { version = "0.12", features = ["futures", "parallel"] }

# TLS. The root-store crates are direct dependencies because the
# outbound paths that are not reqwest — the Realtime WebSocket — have to
# assemble the same trust store by hand (aisix-gateway::upstream_tls).
Expand Down
2 changes: 2 additions & 0 deletions crates/aisix-admin/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2168,6 +2168,7 @@ fn add_variant_titles(doc: &mut Value) {
"OpenAI Moderation",
"Presidio",
"Semantic Screening",
"Custom Script",
],
),
(
Expand Down Expand Up @@ -3090,6 +3091,7 @@ mod tests {
("openai_moderation", "OpenAI Moderation"),
("presidio", "Presidio"),
("semantic", "Semantic Screening"),
("custom", "Custom Script"),
];

let parsed: serde_json::Value =
Expand Down
109 changes: 109 additions & 0 deletions crates/aisix-core/src/models/guardrail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
//! detection-only block.
//! * `presidio` — self-hosted Presidio analyze→anonymize; per-entity
//! `mask`/`block` + selectable anonymize operator.
//! * `custom` — operator-supplied script run in a sandboxed engine in the
//! DP process; reaches a screening service that speaks its own protocol
//! without a separate adapter deployment. Detection-only.
//!
//! See `aisix-guardrails/src/keyword.rs` for the runtime semantics
//! the snapshot is parsed into.
Expand Down Expand Up @@ -828,6 +831,106 @@ fn default_semantic_text_source() -> String {
"user_messages".to_owned()
}

/// Config block for `kind: "custom"`. Runs an operator-supplied script in a
/// sandboxed engine inside the gateway, so a screening service that speaks
/// its own protocol can be reached without deploying a separate adapter.
///
/// The script is an ES module exporting `checkInput` and/or `checkOutput`.
/// Each receives a context object and returns a verdict:
///
/// ```js
/// export async function checkInput(ctx) {
/// const resp = await fetch("https://screening.internal/scan", {
/// method: "POST",
/// headers: { "content-type": "application/json" },
/// body: JSON.stringify({ text: ctx.text }),
/// });
/// const result = await resp.json();
/// return result.verdict === "deny"
/// ? { action: "block", reason_code: "policy" }
/// : { action: "none" };
/// }
/// ```
///
/// A script can allow, block, or rewrite content. Rewriting returns a
/// replacement for each slot in `ctx.segments`; where the call site cannot
/// substitute text back, a rewrite request blocks instead of releasing the
/// original. Scripts also get signing primitives (`crypto`) and access to
/// the environment's embedding model (`aisix.embed`), so a script can
/// express what the built-in kinds express. Applies on input, output, or
/// both, including streamed output.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq)]
pub struct CustomConfig {
/// The script source, as an ES module exporting `checkInput` and/or
/// `checkOutput`. A hook whose function the module does not export is
/// skipped, so a script may cover one direction only.
///
/// Defaulted at the TYPE level and required by the strict write schema
/// instead (AGENTS.md: never make a projected field required at the
/// type level). A row the loader cannot deserialize is skipped whole,
/// and a screening row that vanishes is a guardrail that stopped
/// screening. An empty script is rejected at chain-build time, so such
/// a row is reported rather than silently admitting everything it was
/// meant to screen.
#[serde(default)]
#[schemars(length(min = 1))]
pub script: String,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// Values the script reads as `ctx.secrets.<NAME>`, for credentials the
/// screening service requires. Stored encrypted and decrypted before
/// projection; plaintext is held in memory only and is never logged.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub secrets: std::collections::BTreeMap<String, String>,
/// Wall-clock budget for one hook invocation, in milliseconds, covering
/// the script's own execution and every call it makes. `fail_open` and
/// `output_fail_open` govern the verdict when it elapses. Per-call
/// timeouts within the budget are the script's own to set.
#[serde(default = "default_custom_timeout_ms")]
#[schemars(range(min = 1, max = 300_000))]
pub timeout_ms: u32,
/// Memory ceiling for the script engine, in bytes. A script that exceeds
/// it is terminated and the hook's fail-open policy applies.
#[serde(default = "default_custom_max_memory_bytes")]
#[schemars(range(min = 1_048_576, max = 536_870_912u64))]
pub max_memory_bytes: u64,

// --- streaming-output controls (consumed by aisix-proxy build_sse_stream) ---
/// Streaming output moderation mode: sliding-window incremental release
/// or whole-response hold-back.
#[serde(default = "default_custom_stream_processing_mode")]
pub stream_processing_mode: String,
/// Sliding-window size in characters for window mode.
#[serde(default = "default_acs_window_size")]
#[schemars(range(min = 1, max = 10_000))]
pub window_size: u32,
/// Chars carried between windows so a span split across a boundary is still caught.
#[serde(default = "default_acs_window_overlap_size")]
pub window_overlap_size: u32,
/// Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded` applies.
#[serde(default = "default_acs_max_buffer_bytes")]
#[schemars(range(min = 1))]
pub max_buffer_bytes: u64,
/// Buffer-overflow policy for streamed output when the buffer cap is hit.
#[serde(default = "default_acs_on_buffer_exceeded")]
pub on_buffer_exceeded: String,
/// Fail-open policy for the output hook. When disabled (the default), a
/// script failure blocks model output instead of releasing unscanned
/// content. The input hook uses the top-level `fail_open` policy.
#[serde(default)]
pub output_fail_open: bool,
}

fn default_custom_timeout_ms() -> u32 {
5_000
}

fn default_custom_max_memory_bytes() -> u64 {
16 * 1024 * 1024
}

fn default_custom_stream_processing_mode() -> String {
"window".to_owned()
}

/// Provider discriminator. The kind drives which `*_config` block is
/// expected. Serde's `tag = "kind"` keeps us honest at parse time.
///
Expand Down Expand Up @@ -891,6 +994,11 @@ pub enum GuardrailKind {
/// Detection-only — never rewrites content. Applies on input,
/// output, or both, including buffered streaming output.
Semantic(SemanticConfig),
/// Screening by an operator-supplied script the gateway runs in a
/// sandboxed engine, for a screening service that speaks its own
/// protocol. The script can allow, block, or rewrite content. Applies
/// on input, output, or both, including streaming output.
Custom(CustomConfig),
}

impl GuardrailKind {
Expand All @@ -911,6 +1019,7 @@ impl GuardrailKind {
GuardrailKind::OpenaiModeration(_) => "openai_moderation",
GuardrailKind::Presidio(_) => "presidio",
GuardrailKind::Semantic(_) => "semantic",
GuardrailKind::Custom(_) => "custom",
}
}
}
Expand Down
10 changes: 5 additions & 5 deletions crates/aisix-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@ pub use ensemble::{EnsembleConfig, Judge, PanelMember};
pub use guardrail::{
AliyunAiGuardrailConfig, AliyunTextModerationConfig, AppliedGuardrail,
AzureContentSafetyConfig, AzureContentSafetyTextModerationConfig, BedrockAWSCredentials,
BedrockConfig, BedrockLatencyMode, Guardrail, GuardrailAttachment, GuardrailEnforcedHit,
GuardrailExecution, GuardrailHookPoint, GuardrailKind, GuardrailMetricsSink,
GuardrailMonitorHit, GuardrailScopeType, KeywordConfig, KeywordPattern, LakeraConfig,
OpenaiModerationConfig, PiiConfig, PiiCustomPattern, PiiDetectorConfig, PresidioConfig,
PresidioEntityConfig, SemanticConfig,
BedrockConfig, BedrockLatencyMode, CustomConfig, Guardrail, GuardrailAttachment,
GuardrailEnforcedHit, GuardrailExecution, GuardrailHookPoint, GuardrailKind,
GuardrailMetricsSink, GuardrailMonitorHit, GuardrailScopeType, KeywordConfig, KeywordPattern,
LakeraConfig, OpenaiModerationConfig, PiiConfig, PiiCustomPattern, PiiDetectorConfig,
PresidioConfig, PresidioEntityConfig, SemanticConfig,
};
pub use mcp_auth_settings::McpAuthSettings;
pub use mcp_policy::{McpAccess, McpPolicy, McpPolicyScope};
Expand Down
Loading