Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2645bd7
chore(vendor): bump tiny submodule pins
senamakel Nov 3, 2026
bf81805
chore(vendor): bump tinybus, tinychannels and tinycomputer submodules
senamakel Nov 3, 2026
aa79dec
chore(vendor): bump tinyconnectors and tinydocs submodules
senamakel Nov 3, 2026
ee74a2a
chore(vendor): bump tinyflows submodule
senamakel Nov 3, 2026
9b12b15
test(config): add prompt screen schema tests
senamakel Nov 3, 2026
9a0c2dc
feat(config): add prompt_screen config section
senamakel Nov 3, 2026
61bec33
feat(security): honour prompt_screen mode off in injection detector
senamakel Nov 3, 2026
73ecbce
test(prompt_injection): cover prompt screen enforcement config paths
senamakel Nov 3, 2026
b13ab2a
chore(runtime): log prompt screen mode during context boot
senamakel Nov 3, 2026
c05d354
feat(embed): add prompt_screen knob to the runtime builder
senamakel Nov 3, 2026
a585076
feat(embed): expose prompt screen enforcement in configuration info
senamakel Nov 3, 2026
5bc02bd
chore(vendor): bump tinyhumans-sdk and tinyjuice submodules
senamakel Nov 3, 2026
8108d3b
test(embed): add coverage for prompt screen off behaviour
senamakel Nov 3, 2026
5e0d6a8
test(embed): cover prompt screen enforcement reporting
senamakel Nov 3, 2026
31cff9e
docs(config): document prompt_screen config section
senamakel Nov 3, 2026
7868692
chore(vendor): bump tinymcp and tinymemory submodules
senamakel Nov 3, 2026
0dcfd09
chore(vendor): bump tinysearch, tinysecurity, tinyskills, tinystorage…
senamakel Nov 3, 2026
d9cb31e
chore: apply rustfmt formatting to prompt screen code
senamakel Nov 3, 2026
5ca1cff
test(embed): surface the error when an unscreened turn fails
senamakel Nov 3, 2026
bca3408
docs(embed): update builder setter line references in api index
senamakel Nov 3, 2026
b18b043
test(config): cover prompt screen table parsing and round-trip
senamakel Nov 3, 2026
a9d1055
test(embed): reformat unscreened turn assertion
senamakel Nov 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 crates/openhuman-core/src/config/schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ is mounted as a submodule of [`load/dirs.rs`](./load/dirs.rs) via `#[path]`.
| `[modules]` | [`modules.rs`](./modules.rs) | `ModulesConfig`, `ModuleOverride`: controls only whether compiled-in modules load. The loadable *set* is fixed by `crate::modules::registry` |
| `[observability]` | [`observability.rs`](./observability.rs) | `ObservabilityConfig`, `AgentTracingConfig` |
| `[privacy]` | [`privacy.rs`](./privacy.rs) | `PrivacyConfig`, `PrivacyMode` |
| `[prompt_screen]` | [`prompt_screen.rs`](./prompt_screen.rs) | `PromptScreenConfig`, `PromptScreenMode` (TinySecurity prompt screen; `off` is an explicit opt-out, default enforces and fails closed) |
| `[proxy]` | [`proxy.rs`](./proxy.rs) | `ProxyConfig`, `ProxyScope`, plus `runtime_proxy_config()` / `set_runtime_proxy_config()` process-wide accessors |
| `[[model_routes]]`, `[[embedding_routes]]` | [`routes.rs`](./routes.rs) | `ModelRouteConfig`, `EmbeddingRouteConfig` |
| `[runtime]` (+ `[runtime.docker]`), `[shell]`, `[reliability]`, `[scheduler]` | [`runtime.rs`](./runtime.rs) | `RuntimeConfig`, `DockerRuntimeConfig`, `ShellConfig`, `ReliabilityConfig`, `SchedulerConfig` |
Expand Down
2 changes: 2 additions & 0 deletions crates/openhuman-core/src/config/schema/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ pub(crate) mod memory;
mod modules;
mod observability;
mod privacy;
mod prompt_screen;
mod proxy;
mod routes;
mod runtime;
Expand Down Expand Up @@ -140,6 +141,7 @@ pub use modules::{
};
pub use observability::{AgentTracingBackend, AgentTracingConfig, ObservabilityConfig};
pub use privacy::{PrivacyConfig, PrivacyMode};
pub use prompt_screen::{PromptScreenConfig, PromptScreenMode};
pub use proxy::{
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config,
Expand Down
59 changes: 59 additions & 0 deletions crates/openhuman-core/src/config/schema/prompt_screen.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//! Prompt screen configuration: whether untrusted input is checked for prompt
//! injection by TinySecurity before a turn, a tool result or an inbound
//! message is processed.
//!
//! The screen fails closed. With the default [`PromptScreenMode::Enforce`],
//! input is held for review whenever TinySecurity cannot give a verdict: the
//! module is not compiled in (no `security-module` feature), it is not loaded,
//! it errors, or it times out. A host that screens input somewhere else, or that
//! ships without the TinySecurity module on purpose, opts out with
//! [`PromptScreenMode::Off`]; nothing turns the screen off implicitly.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// Whether the prompt screen runs.
///
/// Serializes as snake_case (`enforce` / `off`). A config missing the
/// `[prompt_screen]` block, or its `mode` key, deserializes to
/// [`PromptScreenMode::Enforce`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PromptScreenMode {
/// Screen every input through TinySecurity, holding it for review when no
/// verdict can be had.
#[default]
Enforce,
/// Do not screen: every input is allowed without consulting TinySecurity.
/// For a host that screens input itself or runs without the module.
Off,
}

/// The `[prompt_screen]` config block.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct PromptScreenConfig {
/// Whether the screen runs. Missing key → [`PromptScreenMode::Enforce`].
pub mode: PromptScreenMode,
}

impl PromptScreenConfig {
/// The screen turned off: [`PromptScreenMode::Off`].
#[must_use]
pub fn off() -> Self {
Self {
mode: PromptScreenMode::Off,
}
}

/// Whether input is screened. False only when the config says
/// [`PromptScreenMode::Off`].
#[must_use]
pub fn enforces(&self) -> bool {
self.mode == PromptScreenMode::Enforce
}
}

#[cfg(test)]
#[path = "prompt_screen_tests.rs"]
mod tests;
33 changes: 33 additions & 0 deletions crates/openhuman-core/src/config/schema/prompt_screen_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
use super::*;

#[test]
fn a_missing_block_enforces_the_screen() {
let config: PromptScreenConfig = toml::from_str("").unwrap();
assert_eq!(config.mode, PromptScreenMode::Enforce);
assert!(config.enforces());
assert!(PromptScreenConfig::default().enforces());
}

#[test]
fn mode_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&PromptScreenMode::Enforce).unwrap(),
"\"enforce\""
);
assert_eq!(
serde_json::to_string(&PromptScreenMode::Off).unwrap(),
"\"off\""
);
}

#[test]
fn off_parses_from_toml_and_stops_enforcing() {
let config: PromptScreenConfig = toml::from_str("mode = \"off\"").unwrap();
assert_eq!(config, PromptScreenConfig::off());
assert!(!config.enforces());
}

#[test]
fn an_unknown_mode_is_rejected_rather_than_read_as_off() {
assert!(toml::from_str::<PromptScreenConfig>("mode = \"disabled\"").is_err());
}
6 changes: 6 additions & 0 deletions crates/openhuman-core/src/config/schema/types/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,12 @@ pub struct Config {
#[serde(default)]
pub privacy: PrivacyConfig,

/// Whether untrusted input is screened for prompt injection
/// (`[prompt_screen]`). Missing block → enforced, failing closed when
/// TinySecurity gives no verdict.
#[serde(default)]
pub prompt_screen: PromptScreenConfig,

#[serde(default)]
pub sandbox: SandboxConfig,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ impl Clone for Config {
computer: self.computer.clone(),
hooks: self.hooks.clone(),
privacy: self.privacy,
prompt_screen: self.prompt_screen,
sandbox: self.sandbox.clone(),
runtime: self.runtime.clone(),
shell: self.shell.clone(),
Expand Down
1 change: 1 addition & 0 deletions crates/openhuman-core/src/config/schema/types/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ impl Default for Config {
computer: ComputerConfig::default(),
hooks: HooksConfig::default(),
privacy: PrivacyConfig::default(),
prompt_screen: PromptScreenConfig::default(),
sandbox: SandboxConfig::default(),
runtime: RuntimeConfig::default(),
shell: ShellConfig::default(),
Expand Down
1 change: 1 addition & 0 deletions crates/openhuman-core/src/core/runtime/context_boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ impl CoreContext {
Ok(cfg) => {
if boot_mode != crate::core::runtime::CoreBootMode::Inspection {
init_stores(&cfg, domains).await;
crate::security::prompt_injection::log_prompt_screen_mode(&cfg);
}
Some(cfg)
}
Expand Down
32 changes: 31 additions & 1 deletion crates/openhuman-core/src/security/prompt_injection/detector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,33 @@ fn held_decision(
decision
}

/// The decision for input the host has chosen not to screen
/// (`[prompt_screen] mode = "off"`). Nothing is logged per input: the runtime
/// says once at boot that screening is off.
fn unscreened_decision(input: &str) -> PromptEnforcementDecision {
PromptEnforcementDecision {
verdict: PromptInjectionVerdict::Allow,
score: 0.0,
reasons: Vec::new(),
action: PromptEnforcementAction::Allow,
prompt_hash: prompt_hash(input),
prompt_chars: input.chars().count(),
}
}

/// Say once, when a runtime boots with a config, whether the prompt screen is
/// off. Silent when it is enforced, which is the default.
pub(crate) fn log_prompt_screen_mode(config: &crate::config::Config) {
if !config.prompt_screen.enforces() {
tracing::info!(
"[prompt_injection] prompt screen disabled by configuration ([prompt_screen] mode = \"off\"); input is not checked by TinySecurity"
);
}
}

/// Screen user or untrusted text through TinySecurity. Missing configuration,
/// module failure, timeout, and oversized inputs all fail closed as Review.
/// module failure, timeout, and oversized inputs all fail closed as Review,
/// unless the configuration turns the screen off.
pub async fn enforce_prompt_input(
input: &str,
context: PromptEnforcementContext<'_>,
Expand Down Expand Up @@ -155,11 +180,16 @@ pub(crate) async fn enforce_prompt_input_in(

/// Screen against the configuration supplied by the owning dispatch. Missing
/// authority remains a held decision, even if another task has a configuration.
/// A configuration with `[prompt_screen] mode = "off"` allows the input without
/// a scan; every screening path goes through here, so all of them honour it.
pub(crate) async fn enforce_prompt_input_with_config(
input: &str,
context: PromptEnforcementContext<'_>,
config: Option<&crate::config::Config>,
) -> PromptEnforcementDecision {
if config.is_some_and(|config| !config.prompt_screen.enforces()) {
return unscreened_decision(input);
Comment on lines +190 to +191

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve persisted config before applying the opt-out

For a normal openhuman-core serve runtime (ConfigSource::Discovered), setting [prompt_screen] mode = "off" in config.toml does not disable the guards that call enforce_prompt_input(). That wrapper reads only CoreContext::current_embedder_config(), while core/runtime/context_boot.rs:187-198 leaves that snapshot absent for discovered-config boots outside SecretPreflight. Consequently, agent_runs/agent_chat/guards.rs:49 passes None here and rejects otherwise valid inference.agent_chat requests as screen.unavailable, even though boot logs that screening is disabled. Resolve the owning dispatch's persisted configuration through the existing config operations, as enforce_prompt_input_in() does, or pass the caller's already-loaded config to these guards.

AGENTS.md reference: AGENTS.md:L252-L253

Useful? React with 👍 / 👎.

Comment on lines +190 to +191
}
if input.len() > MAX_PROMPT_BYTES {
return held_decision(input, context, "input.oversized");
}
Expand Down
4 changes: 3 additions & 1 deletion crates/openhuman-core/src/security/prompt_injection/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

mod detector;

pub(crate) use detector::{enforce_prompt_input_in, enforce_prompt_input_with_config};
pub(crate) use detector::{
enforce_prompt_input_in, enforce_prompt_input_with_config, log_prompt_screen_mode,
};

pub use detector::{
enforce_prompt_input, scan_tool_definition, PromptEnforcementAction, PromptEnforcementContext,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,69 @@ async fn holds_prompts_over_the_security_module_limit() {
.iter()
.any(|reason| reason.code == "input.oversized"));
}

fn context() -> PromptEnforcementContext<'static> {
PromptEnforcementContext {
source: "unit-test",
request_id: None,
user_id: None,
session_id: None,
}
}

fn screen_off_config() -> crate::config::Config {
crate::config::Config {
prompt_screen: crate::config::schema::PromptScreenConfig::off(),
..crate::config::Config::default()
}
}

#[cfg(not(feature = "security-module"))]
#[tokio::test]
async fn a_default_config_holds_input_when_the_security_module_is_not_built() {
let config = crate::config::Config::default();
assert!(config.prompt_screen.enforces());

let decision =
super::enforce_prompt_input_with_config("summarise my inbox", context(), Some(&config))
.await;

assert_eq!(decision.action, PromptEnforcementAction::ReviewBlocked);
assert_eq!(decision.verdict, PromptInjectionVerdict::Review);
}

#[tokio::test]
async fn a_config_that_turns_the_screen_off_allows_input_without_a_scan() {
let config = screen_off_config();

let decision =
super::enforce_prompt_input_with_config("summarise my inbox", context(), Some(&config))
.await;

assert_eq!(decision.action, PromptEnforcementAction::Allow);
assert_eq!(decision.verdict, PromptInjectionVerdict::Allow);
assert!(decision.reasons.is_empty());
assert_eq!(decision.prompt_chars, "summarise my inbox".chars().count());
}

#[tokio::test]
async fn turning_the_screen_off_also_admits_input_over_the_scan_limit() {
let input = "ordinary text ".repeat(MAX_PROMPT_BYTES / 12 + 1);
let config = screen_off_config();

let decision = super::enforce_prompt_input_with_config(&input, context(), Some(&config)).await;

assert_eq!(decision.action, PromptEnforcementAction::Allow);
}

#[tokio::test]
async fn input_without_a_resolved_config_is_held_even_though_another_could_turn_it_off() {
let decision =
super::enforce_prompt_input_with_config("summarise my inbox", context(), None).await;

assert_eq!(decision.action, PromptEnforcementAction::ReviewBlocked);
assert!(decision
.reasons
.iter()
.any(|reason| reason.code == "screen.unavailable"));
}
3 changes: 2 additions & 1 deletion crates/openhuman-embed/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ pub use tinytools::{

/// Typed runtime builder policy groups from the core configuration contract.
pub use openhuman_core::config::schema::{
AutonomyConfig, CronConfig, PrivacyConfig, PrivacyMode, SecretsConfig,
AutonomyConfig, CronConfig, PrivacyConfig, PrivacyMode, PromptScreenConfig, PromptScreenMode,
SecretsConfig,
};
/// The core's config type, as [`crate::RuntimeConfig`].
pub use openhuman_core::config::Config as RuntimeConfig;
Expand Down
17 changes: 17 additions & 0 deletions crates/openhuman-embed/src/runtime/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,23 @@ impl RuntimeBuilder {
self.config_knobs.privacy = Some(value);
self
}
/// Whether untrusted input is screened for prompt injection before a turn,
/// a tool result or an inbound message is processed (`[prompt_screen]`).
///
/// The screen is TinySecurity's, and it fails closed: by default input is
/// held for review whenever no verdict can be had, including in a build
/// without the `security-module` feature. A host that screens input
/// itself, or ships without the TinySecurity module on purpose, passes
/// [`PromptScreenConfig::off`](crate::config::PromptScreenConfig::off); the
/// runtime then allows input unscreened and says so once at boot. Leaving
/// it unset keeps the configured (by default enforced) screen.
pub fn prompt_screen(
mut self,
value: openhuman_core::config::schema::PromptScreenConfig,
) -> Self {
self.config_knobs.prompt_screen = Some(value);
self
}
/// Credential encryption policy.
pub fn secrets(mut self, value: openhuman_core::config::schema::SecretsConfig) -> Self {
self.config_knobs.secrets = Some(value);
Expand Down
16 changes: 15 additions & 1 deletion crates/openhuman-embed/src/runtime/info.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Versioned, credential-safe runtime capability metadata.
use super::{LearningSettings, ModelDefaults, Runtime, RuntimeBuilder};
use openhuman_core::config::{
schema::{AutonomyConfig, CronConfig, PrivacyConfig, SecretsConfig},
schema::{AutonomyConfig, CronConfig, PrivacyConfig, PromptScreenConfig, SecretsConfig},
Config,
};
use std::collections::BTreeMap;
Expand All @@ -11,6 +11,8 @@ pub(super) struct ConfigKnobs {
pub autonomy: Option<AutonomyConfig>,
/// Data egress policy name.
pub privacy: Option<PrivacyConfig>,
/// Whether untrusted input is screened for prompt injection.
pub prompt_screen: Option<PromptScreenConfig>,
pub secrets: Option<SecretsConfig>,
pub cron: Option<CronConfig>,
/// Background memory learning policy.
Expand All @@ -21,6 +23,7 @@ impl ConfigKnobs {
pub fn is_set(&self) -> bool {
self.autonomy.is_some()
|| self.privacy.is_some()
|| self.prompt_screen.is_some()
|| self.secrets.is_some()
|| self.cron.is_some()
|| self.learning.is_some()
Expand All @@ -33,6 +36,9 @@ impl ConfigKnobs {
if let Some(v) = &self.privacy {
config.privacy = *v;
}
if let Some(v) = &self.prompt_screen {
config.prompt_screen = *v;
}
if let Some(v) = &self.secrets {
config.secrets = v.clone();
}
Expand Down Expand Up @@ -169,6 +175,10 @@ pub struct ConfigurationInfo {
pub autonomy_enabled: bool,
/// Data egress policy name.
pub privacy: String,
/// Whether untrusted input is screened for prompt injection; false only
/// when the host turned the screen off. Absent in older metadata → true.
#[serde(default = "prompt_screen_enforced_by_default")]
pub prompt_screen_enforced: bool,
/// Whether stored credentials are encrypted.
pub secrets_encrypted: bool,
/// Whether configured cron scheduling is enabled.
Expand All @@ -178,6 +188,9 @@ pub struct ConfigurationInfo {
/// Whether global tool rules constrain tools through rules or a deny default.
pub has_tool_rules: bool,
}
fn prompt_screen_enforced_by_default() -> bool {
true
}
impl RuntimeBuilder {
/// Describe this builder without booting a core or discovering operator files.
/// Discovered config values are unknown until `Runtime::capabilities()`.
Expand Down Expand Up @@ -310,6 +323,7 @@ fn make_info(
configuration: ConfigurationInfo {
autonomy_enabled: config.autonomy.enabled,
privacy: format!("{:?}", config.privacy.mode),
prompt_screen_enforced: config.prompt_screen.enforces(),
secrets_encrypted: config.secrets.encrypt,
cron_enabled: config.cron.enabled,
learning: LearningSettings {
Expand Down
Loading
Loading