fix(flows): close Composio tool_call tier-gate bypass (C1) (#5000)

This commit is contained in:
Cyrus Gray
2026-07-17 00:10:39 +05:30
committed by GitHub
parent 9fd5fde5d3
commit 70c12ad2ab
5 changed files with 300 additions and 29 deletions
+59 -6
View File
@@ -366,6 +366,28 @@ pub(crate) fn graph_has_outbound_side_effect(graph: &WorkflowGraph) -> bool {
})
}
/// Shared Rule 2 enforcement (issue B29, and its `flows_update` compound-bypass
/// closure): forces `require_approval` to `true` when `graph` contains an
/// outbound side-effect node, no matter what the caller asked for. Used by both
/// [`flows_create`] and [`flows_update`] so a flow can never persist
/// `require_approval: false` alongside a `tool_call` / `http_request` / `code`
/// node — on create OR on a later edit that *adds* such a node to a
/// previously-read-only graph.
///
/// Returns `(effective_require_approval, was_forced)`: `was_forced` is `true`
/// only when the caller's own toggle was `false` but a side-effect node
/// required the override — callers use it to decide whether to emit the
/// loud "forced to true" log/result note.
pub(crate) fn enforce_side_effect_approval(
graph: &WorkflowGraph,
caller_require_approval: bool,
) -> (bool, bool) {
let has_side_effect = graph_has_outbound_side_effect(graph);
let effective_require_approval = caller_require_approval || has_side_effect;
let was_forced = has_side_effect && !caller_require_approval;
(effective_require_approval, was_forced)
}
/// Whether `graph` has anything for [`flows_run`] to actually *do* — i.e. at
/// least one non-`trigger` node **reachable from the trigger** by following
/// directed edges. A graph made of nothing but a bare `trigger` node (or a
@@ -2178,9 +2200,9 @@ pub async fn flows_create(
// Rule 2: any outbound side-effect node forces require_approval, no
// matter what the caller asked for.
let has_side_effect = graph_has_outbound_side_effect(&graph);
let effective_require_approval = require_approval || has_side_effect;
if has_side_effect && !require_approval {
let (effective_require_approval, side_effect_forced) =
enforce_side_effect_approval(&graph, require_approval);
if side_effect_forced {
tracing::info!(
target: "flows",
%name,
@@ -2218,7 +2240,7 @@ pub async fn flows_create(
Enable it explicitly (flows_set_enabled) when you are ready for it to fire."
));
}
if effective_require_approval && !require_approval {
if side_effect_forced {
logs.push(
"require_approval forced to true because the graph contains outbound side-effect \
nodes (tool_call / http_request / code)."
@@ -2638,7 +2660,31 @@ pub async fn flows_update(
"[flows] flows_update: auto-trigger disarm decision inputs"
);
tracing::debug!(target: "flows", flow_id = %id, has_expected = expected_version.is_some(), "[flows] flows_update: persisting changes");
// Rule 2 analogue (compound-bypass closure): re-apply the same outbound
// side-effect check `flows_create` applies on save — via the shared
// [`enforce_side_effect_approval`] helper — so an update that *adds* a
// tool_call/http_request/code node to a previously read-only graph can
// never persist `require_approval: false` just because the update path
// trusted the caller's toggle unconditionally.
let (effective_require_approval, side_effect_forced) =
enforce_side_effect_approval(&graph, new_require_approval);
if side_effect_forced {
tracing::info!(
target: "flows",
flow_id = %id,
"[flows] flows_update: forcing require_approval=true — graph contains outbound \
side-effect node(s) (tool_call / http_request / code)"
);
}
tracing::debug!(
target: "flows",
flow_id = %id,
has_expected = expected_version.is_some(),
require_approval = effective_require_approval,
side_effect_forced,
"[flows] flows_update: persisting changes"
);
// `enabled_override` is threaded into the same guarded UPDATE as the
// graph/name/require_approval write (see `store::update_flow_graph`)
// rather than a follow-up `flows_set_enabled` call, so the disarm can
@@ -2648,7 +2694,7 @@ pub async fn flows_update(
id,
new_name,
graph,
new_require_approval,
effective_require_approval,
enabled_override,
expected_version.as_deref(),
)
@@ -2683,6 +2729,13 @@ pub async fn flows_update(
.to_string(),
);
}
if side_effect_forced {
logs.push(
"require_approval forced to true because the graph contains outbound side-effect \
nodes (tool_call / http_request / code)."
.to_string(),
);
}
Ok(RpcOutcome::new(updated, logs))
}
+71
View File
@@ -4337,6 +4337,77 @@ async fn flows_create_schedule_outbound_creates_disabled_and_approval() {
);
}
#[tokio::test]
async fn flows_update_forces_require_approval_when_adding_side_effect_nodes() {
// Compound bypass fix, half 2: `flows_create`'s Rule 2 (force
// require_approval when the graph gains an outbound side-effect node)
// must also re-apply on `flows_update` — a flow that starts read-only and
// is later edited to add a Composio/http_request/code node must not be
// able to keep require_approval=false just because the update path never
// re-checked.
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false)
.await
.unwrap();
assert!(
!created.value.require_approval,
"a trigger-only graph must not force require_approval on create"
);
let updated = flows_update(
&config,
&created.value.id,
None,
Some(tool_call_graph()),
Some(false),
None,
)
.await
.unwrap();
assert!(
updated.value.require_approval,
"flows_update must force require_approval when the replacement graph adds an outbound \
side-effect node (tool_call), even though the caller passed false"
);
assert!(
updated
.logs
.iter()
.any(|l| l.contains("require_approval forced to true")),
"flows_update must loudly log the forced require_approval: {:?}",
updated.logs
);
}
#[tokio::test]
async fn flows_update_does_not_force_require_approval_on_readonly_graph() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false)
.await
.unwrap();
assert!(!created.value.require_approval);
// Name-only update — no graph change, no side-effect nodes.
let updated = flows_update(
&config,
&created.value.id,
Some("renamed".to_string()),
None,
None,
None,
)
.await
.unwrap();
assert!(
!updated.value.require_approval,
"a name-only update to a read-only graph must not force require_approval"
);
}
// ── graph_has_outbound_side_effect / trigger_is_automatic helper tests ────
#[test]
+67 -22
View File
@@ -1616,6 +1616,7 @@ async fn resolve_composio_account(
/// // approval when a trigger-driven run's tool/http config contains `=`-exprs.
pub struct OpenHumanTools {
pub config: Arc<Config>,
pub security: Arc<SecurityPolicy>,
}
/// Prefix marking a `tool_call` node's slug as a NATIVE OpenHuman tool (the
@@ -2562,18 +2563,13 @@ impl ToolInvoker for OpenHumanTools {
));
}
let security = SecurityPolicy::from_config(
&self.config.autonomy,
&self.config.workspace_dir,
&self.config.action_dir,
);
let class = crate::openhuman::runtime_node::ops::classify_tool_call(
&self.config,
tool_name,
&args,
)
.map_err(EngineError::Capability)?;
let tier_decision = enforce_node_tier_gate(&security, class, "tool_call")?;
let tier_decision = enforce_node_tier_gate(&self.security, class, "tool_call")?;
let summary = crate::openhuman::approval::summarize_action(tool_name, &args);
let redacted = crate::openhuman::approval::redact_args(&args);
let (outcome, _request_id) =
@@ -2601,6 +2597,23 @@ impl ToolInvoker for OpenHumanTools {
});
}
// Autonomy-tier gate (Phase 2): a Composio action reaches a
// third-party API over the network, so it is Network-class — same
// class as `http_request`. A read-only run `Block`s here and never
// reaches curation, the preflight, or the approval gate;
// Supervised/Full fall through to `gate_call_for_tier` below. Runs
// BEFORE the curation check (unlike the pre-fix behavior) so a
// read-only tier can never even probe which slugs are curated.
let tier_decision =
enforce_node_tier_gate(&self.security, CommandClass::Network, "tool_call")?;
tracing::debug!(
target: "flows",
%slug,
?tier_decision,
tier = ?self.security.autonomy,
"[flows] tool_call: composio node tier gate evaluated"
);
// Curation + scope gate — hard allowlist (see [`is_curated_flow_tool`]'s
// doc for why this differs from the general agent tool-call path).
// Runs before anything else — a rejected slug never reaches the
@@ -2623,22 +2636,24 @@ impl ToolInvoker for OpenHumanTools {
// provider or asks the user to approve a call that cannot succeed.
preflight_composio_args(&self.config, slug, &args).await?;
// Approval gate (see the struct doc). Mirrors
// `tinyagents/middleware.rs::ApprovalSecurityMiddleware::wrap_tool`'s
// shape exactly: compute summary/redacted args only when a gate is
// installed, deny short-circuits before any composio call, allow
// records an audit id to close out after the call resolves.
let mut audit_id: Option<String> = None;
if let Some(gate) = crate::openhuman::approval::ApprovalGate::try_global() {
let summary = crate::openhuman::approval::summarize_action(slug, &args);
let redacted = crate::openhuman::approval::redact_args(&args);
let (outcome, request_id) = gate.intercept_audited(slug, &summary, redacted).await;
match outcome {
crate::openhuman::approval::GateOutcome::Deny { reason } => {
return Err(EngineError::Capability(reason));
}
crate::openhuman::approval::GateOutcome::Allow => audit_id = request_id,
}
// Approval gate (see the struct doc). Mirrors `OpenHumanHttp::request`'s
// shape exactly: `gate_call_for_tier` is what actually performs the
// `Prompt` round-trip — it escalates a Supervised `Prompt` decision
// into a forced approval regardless of the flow's own
// `require_approval` toggle (Codex P1), same as the `http_request` and
// `code` node paths. Deny short-circuits before any composio call;
// Allow records an audit id to close out after the call resolves.
let summary = crate::openhuman::approval::summarize_action(slug, &args);
let redacted = crate::openhuman::approval::redact_args(&args);
let (outcome, audit_id) = gate_call_for_tier(tier_decision, slug, &summary, redacted).await;
if let crate::openhuman::approval::GateOutcome::Deny { reason } = outcome {
tracing::warn!(
target: "flows",
%slug,
?tier_decision,
"[flows] tool_call: approval gate denied before Composio dispatch"
);
return Err(EngineError::Capability(reason));
}
let kind = create_composio_client(&self.config)
@@ -3277,6 +3292,7 @@ pub fn build_capabilities(config: Arc<Config>, state_namespace: impl Into<String
}),
tools: Arc::new(OpenHumanTools {
config: config.clone(),
security: security.clone(),
}),
http: Arc::new(OpenHumanHttp {
security: security.clone(),
@@ -4171,6 +4187,35 @@ mod tests {
}
}
/// End-to-end at the adapter: a Composio `tool_call` node under a
/// read-only tier is refused BEFORE it ever reaches the curation gate or
/// any Composio dispatch — closes the compound bypass where the Composio
/// branch of `OpenHumanTools::invoke` reached `intercept_audited` without
/// ever consulting the autonomy tier, unlike the native `oh:`,
/// `http_request`, and `code` node paths, which all gate on tier first.
#[tokio::test]
async fn composio_tool_call_blocks_under_readonly_tier() {
use crate::openhuman::security::AutonomyLevel;
let tools = OpenHumanTools {
config: Arc::new(Config::default()),
security: Arc::new(policy(AutonomyLevel::ReadOnly)),
};
let err = tools
.invoke("SLACK_SEND_MESSAGE", json!({}), None)
.await
.expect_err("read-only tier must block a Composio tool_call node before dispatch");
if let EngineError::Capability(msg) = err {
assert!(
msg.contains(POLICY_BLOCKED_MARKER),
"expected a policy-blocked refusal, got: {msg}"
);
} else {
panic!("expected EngineError::Capability");
}
}
// ── Codex P1: Prompt-tier decisions must escalate past a workflow's own
// require_approval=false default, never silently auto-allow ────────────
+6 -1
View File
@@ -230,7 +230,12 @@ async fn code_adapter_javascript_passthrough_round_trips_json() {
// without any global state.
fn tools_adapter(config: Arc<Config>) -> OpenHumanTools {
OpenHumanTools { config }
let security = Arc::new(SecurityPolicy::from_config(
&config.autonomy,
&config.workspace_dir,
&config.action_dir,
));
OpenHumanTools { config, security }
}
#[tokio::test]
+97
View File
@@ -12862,6 +12862,103 @@ async fn json_rpc_flows_lifecycle_round_trip() {
rpc_join.abort();
}
/// JSON-RPC regression for the Rule 2 compound-bypass fix (C1): `flows_update`
/// must re-derive `require_approval` from the *effective* graph, not trust the
/// caller's raw toggle. This is the RPC-layer counterpart to the direct-API
/// tests `flows_update_forces_require_approval_when_adding_side_effect_nodes`
/// / `flows_update_does_not_force_require_approval_on_readonly_graph` in
/// `src/openhuman/flows/ops_tests.rs` — same rule, exercised through the
/// `openhuman.flows_update` controller (schema + handler wiring), not just
/// the `ops::flows_update` fn directly.
#[cfg(feature = "flows")]
#[tokio::test]
async fn json_rpc_flows_update_forces_require_approval_on_side_effect_graph() {
let _env_lock = json_rpc_e2e_env_lock();
let (rpc_base, _tmp, api_join, rpc_join, _guards) = boot_flows_rpc_env().await;
// 1. Create a trigger-only (read-only) flow with require_approval: false.
let create = post_json_rpc(
&rpc_base,
9401,
"openhuman.flows_create",
json!({
"name": "rpc-rule2-demo",
"graph": {
"name": "trigger-only",
"nodes": [ { "id": "t", "kind": "trigger", "name": "Trigger" } ],
"edges": []
},
"require_approval": false
}),
)
.await;
let flow = peel_logs_envelope(assert_no_jsonrpc_error(&create, "flows_create"));
let flow_id = flow
.get("id")
.and_then(Value::as_str)
.expect("flow id in create result")
.to_string();
assert_eq!(
flow.get("require_approval").and_then(Value::as_bool),
Some(false),
"a trigger-only graph must not force require_approval on create"
);
// 2. Update to a graph with an outbound Composio tool_call node, still
// passing require_approval: false — the RPC handler must force it to
// true rather than trust the caller's toggle.
let update = post_json_rpc(
&rpc_base,
9402,
"openhuman.flows_update",
json!({
"id": flow_id,
"graph": {
"name": "with-tool-call",
"nodes": [
{ "id": "t", "kind": "trigger", "name": "Trigger" },
{
"id": "post",
"kind": "tool_call",
"name": "Post",
"config": { "slug": "SLACK_SEND_MESSAGE", "args": { "channel": "general" } }
}
],
"edges": [ { "from_node": "t", "to_node": "post" } ]
},
"require_approval": false
}),
)
.await;
let updated = peel_logs_envelope(assert_no_jsonrpc_error(&update, "flows_update"));
assert_eq!(
updated.get("require_approval").and_then(Value::as_bool),
Some(true),
"flows_update over JSON-RPC must force require_approval=true when the \
replacement graph adds an outbound side-effect node, even though the \
caller passed false"
);
// 3. The forced value must also be what's persisted, not just what's
// echoed back in the update response.
let get = post_json_rpc(
&rpc_base,
9403,
"openhuman.flows_get",
json!({ "id": flow_id }),
)
.await;
let persisted = peel_logs_envelope(assert_no_jsonrpc_error(&get, "flows_get"));
assert_eq!(
persisted.get("require_approval").and_then(Value::as_bool),
Some(true),
"the forced require_approval must be persisted, not just returned"
);
api_join.abort();
rpc_join.abort();
}
/// Flow Scout suggestion-lifecycle methods over JSON-RPC (no LLM involved):
/// `flows_list_suggestions` starts empty, and `flows_dismiss_suggestion` /
/// `flows_mark_suggestion_built` on an unknown id resolve cleanly and report