Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
1fd11b8
refactor(agent): add AgentTurnOrigin task-local for explicit turn pro…
oxoxDev Jun 2, 2026
fd9b1d4
feat(approval): make turn origin explicit in approval gate decision
oxoxDev Jun 2, 2026
02cab19
feat(channels/web): scope WebChat turn origin around run_turn
oxoxDev Jun 2, 2026
f0accda
feat(channels/runtime): propagate ExternalChannel origin to agent turn
oxoxDev Jun 2, 2026
05b8d13
feat(automation): scope TrustedAutomation origin for subconscious and…
oxoxDev Jun 2, 2026
a137fa6
feat(agent): label remaining run_single entry points with explicit or…
oxoxDev Jun 2, 2026
a17ee1e
feat(memory): track ExternalSync taint on memory entries
oxoxDev Jun 2, 2026
453894d
feat(policy): fail-closed Read default for unmapped channels + migrat…
oxoxDev Jun 2, 2026
c0cfab6
chore(memory): default MemoryTaint::Internal on all in-tree constructors
oxoxDev Jun 2, 2026
e3e3e68
feat(security/policy): reject leading env-var assignments in shell co…
oxoxDev Jun 2, 2026
8e22909
test(agent_tool_policy): align prompt-boundary test with new fail-clo…
oxoxDev Jun 2, 2026
956e637
fix(policy): preserve legacy empty-map unrestricted in engine; migrat…
oxoxDev Jun 2, 2026
0d38ece
style: cargo fmt + tighten migrate_channel_permissions_if_legacy sema…
oxoxDev Jun 2, 2026
80e2dcb
chore(security,config): docstring polish + drop redundant trim_start
oxoxDev Jun 2, 2026
9ffb8b2
feat(memory_store): track taint provenance on namespace document DTOs
oxoxDev Jun 2, 2026
b66af79
feat(memory_store): add taint column with idempotent ALTER TABLE migr…
oxoxDev Jun 2, 2026
9d9da78
feat(memory_store): persist and surface taint on document upsert and …
oxoxDev Jun 2, 2026
94871ff
feat(memory): expose store_with_taint and surface real taint in recall
oxoxDev Jun 2, 2026
09e6fce
feat(memory_sync): mark composio-ingested chunks with ExternalSync taint
oxoxDev Jun 2, 2026
53703e9
feat(subconscious): upgrade tick origin to SubconsciousTainted when c…
oxoxDev Jun 2, 2026
9d386a0
test(memory): regression coverage for taint persistence and origin up…
oxoxDev Jun 2, 2026
36dfcdb
fix(memory): fail closed on unknown memory_docs.taint values
oxoxDev Jun 2, 2026
b2aada0
docs(memory_store): note fail-closed unknown-taint behavior at load site
oxoxDev Jun 2, 2026
a831f27
fix(meet_agent): classify live-meeting turns as ExternalChannel not Cli
oxoxDev Jun 2, 2026
7516b5f
docs(config): correct channel_permissions empty-map runtime semantics
oxoxDev Jun 2, 2026
5dca63d
fix(agent/turn_origin): box-pin scoped future to keep agent loop off …
oxoxDev Jun 2, 2026
974e6e9
test(approval): scope WebChat origin in worker_b raw-coverage e2e
oxoxDev Jun 2, 2026
25abd8f
test(approval): align tool_registry approval e2e with explicit-origin…
oxoxDev Jun 2, 2026
ebd62b1
Merge branch 'main' into pr/3227
senamakel Jun 3, 2026
47a1496
fix(agent/bus): remove duplicate doc comment on AgentTurnRequest
senamakel Jun 3, 2026
5f4b79c
fix(skills): inherit parent turn origin in spawned skill runs
senamakel Jun 3, 2026
6904e60
fix(memory_store): preserve taint in get() and list() paths
senamakel Jun 3, 2026
7f88872
test(memory_store/safety): add taint preservation regression test
senamakel Jun 3, 2026
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
1 change: 1 addition & 0 deletions src/core/memory_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ fn run_ingest(args: &[String]) -> Result<()> {
category: "core".to_string(),
session_id: None,
document_id: None,
taint: crate::openhuman::memory::MemoryTaint::Internal,
};

let ingestion_config = MemoryIngestionConfig::default();
Expand Down
85 changes: 56 additions & 29 deletions src/openhuman/agent/bus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use tokio::sync::mpsc;

use crate::core::event_bus::register_native_global;
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::agent::turn_origin::{self, AgentTurnOrigin};
use crate::openhuman::config::MultimodalConfig;
use crate::openhuman::inference::provider::{ChatMessage, Provider};
use crate::openhuman::prompt_injection::{
Expand Down Expand Up @@ -128,6 +129,20 @@ pub struct AgentTurnRequest {
/// progressively edit outbound messages. `None` disables streaming
/// status updates for this turn.
pub on_progress: Option<mpsc::Sender<AgentProgress>>,

/// Trust/routing label for this turn. The bus handler scopes
/// [`AGENT_TURN_ORIGIN`](crate::openhuman::agent::turn_origin::AGENT_TURN_ORIGIN)
/// around the tool loop so the approval gate (and any other
/// origin-aware policy) sees the same value the caller intended.
///
/// Native-bus dispatch crosses a `tokio::spawn` boundary inside the
/// registry, so callers cannot rely on `AGENT_TURN_ORIGIN` propagating
/// implicitly — the origin must travel as an owned field.
///
/// Defaults to [`AgentTurnOrigin::Unknown`] so any caller that fails
/// to populate this fails closed at the gate rather than silently
/// inheriting trusted-automation semantics.
pub origin: AgentTurnOrigin,
}

/// Final response from an agentic turn.
Expand Down Expand Up @@ -163,6 +178,7 @@ pub fn register_agent_handlers() {
visible_tool_names,
extra_tools,
on_progress,
origin,
} = req;

tracing::debug!(
Expand Down Expand Up @@ -239,35 +255,45 @@ pub fn register_agent_handlers() {
.map(|def| def.sandbox_mode)
.unwrap_or(SandboxMode::None);

let text = with_current_sandbox_mode(sandbox_mode, async {
run_tool_call_loop(
provider.as_ref(),
&mut history,
tools_registry.as_ref(),
&provider_name,
&model,
temperature,
silent,
&channel_name,
&multimodal,
&multimodal_files,
max_tool_iterations,
on_delta,
visible_tool_names.as_ref(),
&extra_tools,
on_progress,
// Bus path runs ad-hoc agent turns without an Agent
// handle, so we pass None — payload summarization is
// wired into the orchestrator session via Agent::turn,
// not the bus dispatcher.
None,
// Use the default (allow-all) tool policy. Custom
// policies can be wired in via AgentTurnRequest when
// per-channel policy configuration is added (#2134).
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
})
// Scope the caller-supplied origin around the tool loop so
// the approval gate (and any other origin-aware policy) sees
// the same trust label the entry point intended. Native-bus
// dispatch crosses a `tokio::spawn` boundary inside the
// registry, so re-scoping here is mandatory — the
// task-local does NOT propagate across that boundary
// implicitly.
let text = turn_origin::with_origin(
origin,
with_current_sandbox_mode(sandbox_mode, async {
run_tool_call_loop(
provider.as_ref(),
&mut history,
tools_registry.as_ref(),
&provider_name,
&model,
temperature,
silent,
&channel_name,
&multimodal,
&multimodal_files,
max_tool_iterations,
on_delta,
visible_tool_names.as_ref(),
&extra_tools,
on_progress,
// Bus path runs ad-hoc agent turns without an Agent
// handle, so we pass None — payload summarization is
// wired into the orchestrator session via Agent::turn,
// not the bus dispatcher.
None,
// Use the default (allow-all) tool policy. Custom
// policies can be wired in via AgentTurnRequest when
// per-channel policy configuration is added (#2134).
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
}),
)
.await
.map_err(|e| e.to_string())?;

Expand Down Expand Up @@ -415,6 +441,7 @@ mod tests {
visible_tool_names: None,
extra_tools: Vec::new(),
on_progress: None,
origin: AgentTurnOrigin::Cli,
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/openhuman/agent/harness/memory_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ mod tests {
timestamp: "now".into(),
session_id: None,
score,
taint: Default::default(),
}
}

Expand All @@ -297,6 +298,7 @@ mod tests {
timestamp: "now".into(),
session_id: Some(session_id.into()),
score,
taint: Default::default(),
}
}

Expand Down
1 change: 1 addition & 0 deletions src/openhuman/agent/harness/memory_context_safety.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ mod tests {
timestamp: "2026-05-20T00:00:00Z".into(),
session_id: None,
score: None,
taint: Default::default(),
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/openhuman/agent/memory_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,7 @@ mod tests {
timestamp: "2026-04-22T00:00:00Z".to_string(),
session_id: None,
score,
taint: Default::default(),
}
}

Expand Down Expand Up @@ -570,6 +571,7 @@ mod tests {
timestamp: "2026-05-25T00:00:00Z".into(),
session_id: None,
score: None,
taint: Default::default(),
}]);

let out = DefaultMemoryLoader::default()
Expand Down Expand Up @@ -600,6 +602,7 @@ mod tests {
timestamp: "2026-04-22T00:00:00Z".into(),
session_id: Some("thr_old".into()),
score: Some(0.9),
taint: Default::default(),
}]);

let out = DefaultMemoryLoader::default()
Expand Down Expand Up @@ -632,6 +635,7 @@ mod tests {
timestamp: "2026-04-22T00:00:00Z".into(),
session_id: Some("thr_old".into()),
score: Some(0.9),
taint: Default::default(),
},
MemoryEntry {
id: "id-2".into(),
Expand All @@ -642,6 +646,7 @@ mod tests {
timestamp: "2026-04-22T00:00:00Z".into(),
session_id: None,
score: Some(0.9),
taint: Default::default(),
},
]);

Expand Down Expand Up @@ -700,6 +705,7 @@ mod tests {
timestamp: "2026-05-15T00:00:00Z".into(),
session_id: Some(session_id.into()),
score,
taint: Default::default(),
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/openhuman/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ pub mod tool_policy;
pub mod tools;
pub mod tree_loader;
pub mod triage;
/// Turn-origin task-local — explicit trust/routing label scoped by every
/// entry point that invokes the agent (web chat, channel runtime,
/// subconscious, cron, CLI). Read by the approval gate to make
/// origin-aware decisions rather than inferring trust from the absence of
/// `APPROVAL_CHAT_CONTEXT`.
pub mod turn_origin;
pub use schemas::{
all_controller_schemas as all_agent_controller_schemas,
all_registered_controllers as all_agent_registered_controllers,
Expand Down
13 changes: 10 additions & 3 deletions src/openhuman/agent/task_dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,9 +378,16 @@ async fn run_autonomous(
run_id.get(..8).unwrap_or(run_id)
));

with_autonomous_iter_cap(TASK_RUN_MAX_ITERATIONS, agent.run_single(prompt))
.await
.map_err(|e| format!("{e:#}"))
// Sub-agent task runs are internal to the agent harness — the user
// already authorized the parent turn that dispatched this task. Label
// as CLI so the approval gate doesn't fail closed on internal
// sub-agent invocations.
crate::openhuman::agent::turn_origin::with_origin(
crate::openhuman::agent::turn_origin::AgentTurnOrigin::Cli,
with_autonomous_iter_cap(TASK_RUN_MAX_ITERATIONS, agent.run_single(prompt)),
)
.await
.map_err(|e| format!("{e:#}"))
}

/// Deterministic board write-back: the dispatcher owns the card lifecycle.
Expand Down
10 changes: 10 additions & 0 deletions src/openhuman/agent/triage/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,16 @@ async fn try_arm(
visible_tool_names: None,
extra_tools: Vec::new(),
on_progress: None,
// Triage processes untrusted inbound channel text. Label it as
// ExternalChannel so the approval gate treats any external_effect
// tool call originating from this turn as remote-attacker input
// (the triage agent doesn't usually invoke such tools — it
// classifies and routes — but label correctly for defense in depth).
origin: crate::openhuman::agent::turn_origin::AgentTurnOrigin::ExternalChannel {
channel: envelope.source.slug().to_string(),
reply_target: envelope.display_label.clone(),
message_id: envelope.external_id.clone(),
},
};

let response = match request_native_global::<AgentTurnRequest, AgentTurnResponse>(
Expand Down
143 changes: 143 additions & 0 deletions src/openhuman/agent/turn_origin.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//! Agent turn origin — the trust/routing label attached to every agent
//! `run_turn` invocation. Read by [`crate::openhuman::approval::ApprovalGate`]
//! and [`crate::openhuman::agent_tool_policy::ToolPolicyEngine`] to make
//! consistent decisions across web, channel, subconscious, and cron entry
//! points without relying on the *absence* of other task-locals as a signal.
//!
//! Every entry point that drives the agent loop ([`crate::openhuman::channels::providers::web`],
//! [`crate::openhuman::channels::runtime::dispatch`], [`crate::openhuman::subconscious`],
//! [`crate::openhuman::cron`], CLI) MUST scope a real [`AgentTurnOrigin`]
//! around its `run_turn` invocation. Any path that fails to do so is treated
//! as [`AgentTurnOrigin::Unknown`] by the gate and the call fails closed.

/// Identifies who scheduled the current agent turn so the approval gate can
/// pick the correct policy: surface to the user, persist for an
/// out-of-band approval surface, run trusted-automation through, or fail
/// closed.
///
/// This is a typed task-local label, not a credential — it is set by the
/// entry point that owns the turn and read by [`crate::openhuman::approval`]
/// alongside the existing per-turn chat context.
#[derive(Clone, Debug)]
pub enum AgentTurnOrigin {
/// Live user chat in the desktop / web UI. The existing
/// [`crate::openhuman::approval::ApprovalChatContext`] task-local is
/// scoped alongside this so the approval gate has a thread / client to
/// route the prompt back to.
WebChat {
thread_id: String,
client_id: String,
},
/// Inbound message from a non-web channel (Telegram / Discord / Slack /
/// Yuanbao / etc.). External-effect tools must persist a
/// `pending_approvals` row for the audit trail; the parked future will
/// TTL-deny because no caller picks up the chat-routed approval on this
/// surface yet — which is the correct fail-closed default for remote
/// inputs.
ExternalChannel {
channel: String,
reply_target: String,
message_id: String,
},
/// Internal automation the user explicitly authorized (cron job the
/// user created, subconscious tick on internal-only memory). `source`
/// carries enough info for the gate to apply the right per-source
/// allowlist.
TrustedAutomation {
job_id: String,
source: TrustedAutomationSource,
},
/// Command-line / sub-agent / one-off internal invocation.
Cli,
/// Unlabelled — gate fails closed. Every entry point MUST scope a real
/// origin before invoking the agent.
Unknown,
}

/// Sub-classification for [`AgentTurnOrigin::TrustedAutomation`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TrustedAutomationSource {
/// Cron job created and authorized by the user.
Cron,
/// Subconscious tick whose memory context is internal-only.
Subconscious,
/// Subconscious tick whose memory context includes chunks ingested
/// from an external sync source (Gmail / Slack / Notion / etc.).
/// Treated as untrusted: external-effect tool surface blocked.
SubconsciousTainted,
}

tokio::task_local! {
/// Per-turn agent origin. Scoped by entry points (web channel, channel
/// runtime dispatch, subconscious loop, cron scheduler, CLI) around the
/// `run_turn` invocation. Read by the approval gate to make
/// origin-aware decisions.
pub static AGENT_TURN_ORIGIN: AgentTurnOrigin;
}

/// Scope `origin` for the duration of `fut`. Mirrors the existing
/// [`crate::openhuman::approval::APPROVAL_CHAT_CONTEXT`] scope pattern.
pub async fn with_origin<F: std::future::Future>(origin: AgentTurnOrigin, fut: F) -> F::Output {
AGENT_TURN_ORIGIN.scope(origin, fut).await
}

/// Try to read the current origin. Returns `None` when no caller scoped one
/// (legacy callers that haven't been migrated yet — the gate maps this to
/// [`AgentTurnOrigin::Unknown`] / fail-closed).
pub fn current() -> Option<AgentTurnOrigin> {
AGENT_TURN_ORIGIN.try_with(|o| o.clone()).ok()
}

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
async fn with_origin_scopes_correctly_and_unscopes_on_exit() {
// Outside any scope: current() returns None.
assert!(current().is_none());

let observed = with_origin(AgentTurnOrigin::Cli, async {
// Inside the scope: current() returns the scoped origin.
current()
})
.await;
assert!(matches!(observed, Some(AgentTurnOrigin::Cli)));

// After the scope exits, current() is None again.
assert!(current().is_none());
}

#[tokio::test]
async fn current_returns_none_outside_scope() {
assert!(current().is_none());
}

#[tokio::test]
async fn current_returns_inner_origin_on_nested_scope() {
let observed = with_origin(
AgentTurnOrigin::WebChat {
thread_id: "outer".into(),
client_id: "c-outer".into(),
},
async {
with_origin(
AgentTurnOrigin::TrustedAutomation {
job_id: "j-1".into(),
source: TrustedAutomationSource::Cron,
},
async { current() },
)
.await
},
)
.await;
match observed {
Some(AgentTurnOrigin::TrustedAutomation { job_id, source }) => {
assert_eq!(job_id, "j-1");
assert_eq!(source, TrustedAutomationSource::Cron);
}
other => panic!("expected inner TrustedAutomation, got {other:?}"),
}
}
}
Loading
Loading