Skip to content
Open
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
4 changes: 2 additions & 2 deletions go/gen/sift/webhooks/v1/webhooks.pb.go

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

4 changes: 2 additions & 2 deletions protos/sift/webhooks/v1/webhooks.proto
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ message ListWebhooksRequest {

// How to order the retrieved webhooks. Formatted as a comma-separated string i.e. "FIELD_NAME[ desc],...".
// Available field to order_by is `created_date`.
// If left empty, items are ordered by `created_date` in ascending order (oldest-first).
// If left empty, items are ordered by `created_date` in descending order (newest-first).
// For more information about the format of this field, read [this](https://google.aip.dev/132#ordering)
// Example: "created_date desc"
string order_by = 4 [(google.api.field_behavior) = OPTIONAL];
Expand Down Expand Up @@ -351,7 +351,7 @@ message ListWebhookLogsRequest {

// How to order the retrieved webhook logs. Formatted as a comma-separated string i.e. "FIELD_NAME[ desc],...".
// Available field to order_by is `created_date`.
// If left empty, items are ordered by `created_date` in ascending order (oldest-first).
// If left empty, items are ordered by `created_date` in descending order (newest-first).
// For more information about the format of this field, read [this](https://google.aip.dev/132#ordering)
// Example: "created_date desc"
string order_by = 4 [(google.api.field_behavior) = OPTIONAL];
Expand Down
Empty file added python/changelog.d/837.chore.md
Empty file.
4 changes: 2 additions & 2 deletions python/lib/sift/webhooks/v1/webhooks_pb2.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ class ListWebhooksRequest(google.protobuf.message.Message):
order_by: builtins.str
"""How to order the retrieved webhooks. Formatted as a comma-separated string i.e. "FIELD_NAME[ desc],...".
Available field to order_by is `created_date`.
If left empty, items are ordered by `created_date` in ascending order (oldest-first).
If left empty, items are ordered by `created_date` in descending order (newest-first).
For more information about the format of this field, read [this](https://google.aip.dev/132#ordering)
Example: "created_date desc"
"""
Expand Down Expand Up @@ -658,7 +658,7 @@ class ListWebhookLogsRequest(google.protobuf.message.Message):
order_by: builtins.str
"""How to order the retrieved webhook logs. Formatted as a comma-separated string i.e. "FIELD_NAME[ desc],...".
Available field to order_by is `created_date`.
If left empty, items are ordered by `created_date` in ascending order (oldest-first).
If left empty, items are ordered by `created_date` in descending order (newest-first).
For more information about the format of this field, read [this](https://google.aip.dev/132#ordering)
Example: "created_date desc"
"""
Expand Down
13 changes: 13 additions & 0 deletions rust/crates/sift_cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### What's New

- New `list_webhooks` tool lists the webhooks a rule can call. It withholds
credentials: `target_url` keeps only its scheme and the last two labels of
its host, each header keeps only its name, and the request body template is
left out.
- `create_rule` and `update_rule` take `webhook_ids`, so an agent can make a
rule call a webhook when it fires. On `update_rule` it replaces the rule's
set, like `asset_ids`, and the result names any webhook it detached. Both
tools check that each webhook exists and is not archived before saving the
rule, and refuse webhook actions written into the rule's conditions.
- `create_rule` now documents the annotation action and says a rule takes
exactly one condition. Agents no longer need to copy an existing rule to get
the actions right.

## [v0.7.4] - October 14, 2026

### What's New
Expand Down
8 changes: 7 additions & 1 deletion rust/crates/sift_cli/assets/skills/sift/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ tool.
connectivity check; when it fails, expect every other Sift tool to fail too.
- **Discovery:** `list_assets`, `list_runs`, `list_channels`, `list_reports`,
`list_report_templates`, `list_rules`, `list_rule_versions`, `list_annotations`,
`list_artifacts`, `list_artifact_versions`.
`list_artifacts`, `list_artifact_versions`, `list_webhooks`.
- **Artifacts:** `download_artifact` (latest version, or pin `artifact_version_id`),
`list_artifact_versions` for one artifact's history, newest first.
The artifact tools, including `list_artifacts` and the artifact writes below,
Expand Down Expand Up @@ -251,6 +251,12 @@ tool.
cannot be attached to a template) — skip the template and pass `rule_ids`,
`rule_client_keys`, or `rule_version_ids` directly to `create_report`. Track
progress via `list_report_rule_summaries`.
- **Alert outside Sift when a rule fires.** Find the webhook with
`list_webhooks`, then pass its id in `webhook_ids` on `create_rule` or
`update_rule`. On `update_rule` it replaces the rule's set, like `asset_ids`,
so include the webhooks the rule already calls. Do not write webhook actions
into `conditions`. These tools cannot create a webhook; when none fits, tell
the user to create one in the Sift web app.

## Rules that always apply

Expand Down
7 changes: 6 additions & 1 deletion rust/crates/sift_mcp/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ use crate::service::{
rule_evaluation::RuleEvaluationService, rules::RuleService, runs::RunService,
test_reports::TestReportService, url::UrlService,
user_defined_functions::UserDefinedFunctionService, users::UserService,
webhooks::WebhookService,
};

#[derive(Clone)]
Expand All @@ -78,6 +79,7 @@ pub struct SiftMcpServer {
pub docs_service: DocsService,
pub user_defined_function_service: UserDefinedFunctionService,
pub user_service: UserService,
pub webhook_service: WebhookService,

pub allow_create: bool,
pub allow_destructive: bool,
Expand Down Expand Up @@ -234,6 +236,7 @@ impl SiftMcpServer {
tool_router.merge(Self::docs_router());
tool_router.merge(Self::user_defined_functions_router());
tool_router.merge(Self::users_router());
tool_router.merge(Self::webhooks_router());
tool_router.merge(Self::update_router());
let registered_tools: HashSet<String> = tool_router
.list_all()
Expand Down Expand Up @@ -300,7 +303,8 @@ impl SiftMcpServer {
let docs_service = DocsService::new(channel.clone(), retry_policy.clone());
let user_defined_function_service =
UserDefinedFunctionService::new(channel.clone(), retry_policy.clone());
let user_service = UserService::new(channel.clone(), retry_policy);
let user_service = UserService::new(channel.clone(), retry_policy.clone());
let webhook_service = WebhookService::new(channel.clone(), retry_policy);

Ok(Self {
annotation_service,
Expand All @@ -321,6 +325,7 @@ impl SiftMcpServer {
docs_service,
user_defined_function_service,
user_service,
webhook_service,
tool_router,
prompt_router,
allow_create,
Expand Down
1 change: 1 addition & 0 deletions rust/crates/sift_mcp/src/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ pub mod test_reports;
pub mod url;
pub mod user_defined_functions;
pub mod users;
pub mod webhooks;

pub(crate) mod common;
165 changes: 153 additions & 12 deletions rust/crates/sift_mcp/src/service/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ use anyhow::{Context, Result};
use sift_rs::{
SiftChannel,
rules::v1::{
ArchiveRuleRequest, CreateRuleRequest, GetRuleRequest, ListRuleVersionsRequest,
ActionKind, ArchiveRuleRequest, CreateRuleRequest, GetRuleRequest, ListRuleVersionsRequest,
ListRuleVersionsResponse, ListRulesRequest, ListRulesResponse, Rule, RuleAction,
RuleCondition, RuleVersion, UnarchiveRuleRequest, UpdateActionRequest,
UpdateConditionRequest, UpdateRuleRequest, rule_service_client::RuleServiceClient,
RuleActionConfiguration, RuleCondition, RuleVersion, UnarchiveRuleRequest,
UpdateActionRequest, UpdateConditionRequest, UpdateRuleRequest, WebhookActionConfiguration,
rule_action_configuration::Configuration, rule_service_client::RuleServiceClient,
},
};

/// A partial set of changes to apply to an existing rule. Every field is
/// optional; `None` means "leave unchanged". This is what makes `update_rule`
/// optional; `None` means "leave unchanged". This is what makes an update
/// safe: only the fields set here are touched, everything else on the rule is
/// preserved across the full-replace `UpdateRule` RPC.
#[derive(Debug, Default)]
Expand All @@ -24,6 +25,22 @@ pub struct RuleUpdate {
pub asset_ids: Option<Vec<String>>,
pub tag_ids: Option<Vec<String>>,
pub conditions: Option<Vec<UpdateConditionRequest>>,
/// Replaces the set of webhooks the rule calls. `None` keeps the current
/// set, including when `conditions` replaces the actions they sat on.
pub webhook_ids: Option<Vec<String>>,
}

/// An update merged over the current rule and ready to save with
/// [`RuleService::save_update`]. Splitting the fetch from the save lets the
/// caller check the new webhooks against the ones the rule already calls
/// without fetching the rule twice.
#[derive(Debug)]
pub struct PendingRuleUpdate {
update: UpdateRuleRequest,
/// Webhooks the rule calls before the update.
pub current_webhook_ids: Vec<String>,
/// Webhooks the rule calls before the update and will no longer call.
pub detached_webhook_ids: Vec<String>,
}

#[cfg(test)]
Expand Down Expand Up @@ -110,8 +127,15 @@ impl RuleService {
})
}

/// Creates a rule from a full rule definition. Returns the new `rule_id`.
pub async fn create_rule(&self, update: UpdateRuleRequest) -> Result<String> {
/// Creates a rule from a full rule definition that calls each of
/// `webhook_ids` when it fires. Returns the new `rule_id`.
pub async fn create_rule(
&self,
mut update: UpdateRuleRequest,
webhook_ids: Vec<String>,
) -> Result<String> {
attach_webhooks(&mut update.conditions, &webhook_ids);

let channel = self.channel.clone();

let resp = with_retry(&self.policy, move || {
Expand All @@ -133,17 +157,22 @@ impl RuleService {
Ok(resp.rule_id)
}

/// Safely updates an existing rule by `rule_id`. The underlying `UpdateRule`
/// RPC is a full replace, so this fetches the current rule, overlays only the
/// fields set in `changes`, and sends the merged definition back. Fields not
/// named in `changes` are preserved. Returns the `rule_id`.
pub async fn update_rule(&self, rule_id: String, changes: RuleUpdate) -> Result<String> {
/// Fetches the rule and overlays only the fields set in `changes`. The
/// `UpdateRule` RPC is a full replace, so fields not named in `changes` are
/// carried over from the current rule. Nothing is written until
/// [`Self::save_update`].
pub async fn prepare_update(
&self,
rule_id: String,
changes: RuleUpdate,
) -> Result<PendingRuleUpdate> {
let current = self
.get_rule(rule_id.clone())
.await?
.ok_or_else(|| anyhow::anyhow!("rule '{rule_id}' not found"))?;

let mut update = rule_to_update_request(current);
let current_webhook_ids = webhook_ids_in(&update.conditions);

let RuleUpdate {
name,
Expand All @@ -153,6 +182,7 @@ impl RuleService {
asset_ids,
tag_ids,
conditions,
webhook_ids,
} = changes;

if let Some(name) = name {
Expand Down Expand Up @@ -180,7 +210,33 @@ impl RuleService {
if let Some(conditions) = conditions {
update.conditions = conditions;
}
let detached_webhook_ids = match webhook_ids {
Some(webhook_ids) => {
set_webhooks(&mut update.conditions, &webhook_ids);
current_webhook_ids
.iter()
.filter(|id| !webhook_ids.contains(id))
.cloned()
.collect()
}
// Re-attaches the current webhooks when `conditions` replaced the
// actions they sat on, and changes nothing otherwise.
None => {
attach_webhooks(&mut update.conditions, &current_webhook_ids);
Vec::new()
}
};

Ok(PendingRuleUpdate {
update,
current_webhook_ids,
detached_webhook_ids,
})
}

/// Saves an update from [`Self::prepare_update`]. Returns the `rule_id`.
pub async fn save_update(&self, pending: PendingRuleUpdate) -> Result<String> {
let update = pending.update;
let channel = self.channel.clone();
let resp = with_retry(&self.policy, move || {
let channel = channel.clone();
Expand Down Expand Up @@ -337,7 +393,7 @@ impl RuleService {

/// Converts a fetched [`Rule`] into the [`UpdateRuleRequest`] shape so it can be
/// resent through the full-replace `UpdateRule` RPC. Every meaningful field is
/// carried over verbatim; this is the base that `update_rule` overlays changes
/// carried over verbatim; this is the base that `prepare_update` overlays changes
/// onto so unspecified fields survive the round-trip.
fn rule_to_update_request(rule: Rule) -> UpdateRuleRequest {
UpdateRuleRequest {
Expand Down Expand Up @@ -384,3 +440,88 @@ fn action_to_update(action: RuleAction) -> UpdateActionRequest {
configuration: action.configuration,
}
}

/// Makes every condition call exactly `webhook_ids`. Sift fires a webhook from
/// the condition that holds it and allows one condition per rule, so every
/// condition is that one. Every other webhook action is dropped, including one
/// typed `WEBHOOK` that names no webhook, which Sift saves but cannot call.
/// Non-webhook actions are left alone.
fn set_webhooks(conditions: &mut [UpdateConditionRequest], webhook_ids: &[String]) {
for condition in conditions.iter_mut() {
condition.actions.retain(|action| {
!is_webhook_action(action)
|| webhook_id(action).is_some_and(|id| webhook_ids.iter().any(|w| w == id))
});
}
attach_webhooks(conditions, webhook_ids);
}

/// Attaches each of `webhook_ids` that a condition does not already call. An
/// action for a webhook already attached keeps its `rule_action_id`, since
/// Sift deletes any action an update leaves out, and a repeated id is attached
/// once because Sift rejects the rule otherwise.
fn attach_webhooks(conditions: &mut [UpdateConditionRequest], webhook_ids: &[String]) {
for condition in conditions {
for id in webhook_ids {
let attached = condition
.actions
.iter()
.any(|action| webhook_id(action) == Some(id.as_str()));
if !attached {
condition.actions.push(webhook_action(id.clone()));
}
}
}
}

/// Whether any condition carries a webhook action. The rule tools take
/// webhooks only through `webhook_ids`, so each one is checked before saving.
pub fn has_webhook_action(conditions: &[UpdateConditionRequest]) -> bool {
Comment thread
bijan-sift marked this conversation as resolved.
conditions
.iter()
.flat_map(|condition| &condition.actions)
.any(is_webhook_action)
}

/// Whether an action is a webhook action by its type or its configuration.
/// Sift checks a configuration only when one is set, so an action typed
/// `WEBHOOK` with no configuration still saves, as a webhook action with no
/// webhook.
fn is_webhook_action(action: &UpdateActionRequest) -> bool {
action.action_type == ActionKind::Webhook as i32 || webhook_id(action).is_some()
}

/// The webhooks the conditions call, in order, each once.
fn webhook_ids_in(conditions: &[UpdateConditionRequest]) -> Vec<String> {
let mut ids: Vec<String> = Vec::new();
for id in conditions
.iter()
.flat_map(|condition| &condition.actions)
.filter_map(webhook_id)
{
if !ids.iter().any(|existing| existing == id) {
ids.push(id.to_string());
}
}
ids
}

/// The webhook an action calls, or `None` for any other kind of action.
fn webhook_id(action: &UpdateActionRequest) -> Option<&str> {
match action.configuration.as_ref()?.configuration.as_ref()? {
Configuration::Webhook(webhook) => Some(webhook.webhook_id.as_str()),
_ => None,
}
}

fn webhook_action(webhook_id: String) -> UpdateActionRequest {
UpdateActionRequest {
rule_action_id: None,
action_type: ActionKind::Webhook as i32,
configuration: Some(RuleActionConfiguration {
configuration: Some(Configuration::Webhook(WebhookActionConfiguration {
webhook_id,
})),
}),
}
}
Loading
Loading