feat(security): add safety heartbeat reinjection with cadence fixes

This commit is contained in:
argenis de la rosa 2026-02-28 08:46:58 -05:00 committed by Argenis
parent 3341608d52
commit a029c720a6
4 changed files with 374 additions and 80 deletions

View File

@ -289,6 +289,20 @@ pub(crate) struct NonCliApprovalContext {
tokio::task_local! {
static TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT: Option<NonCliApprovalContext>;
static LOOP_DETECTION_CONFIG: LoopDetectionConfig;
static SAFETY_HEARTBEAT_CONFIG: Option<SafetyHeartbeatConfig>;
}
/// Configuration for periodic safety-constraint re-injection (heartbeat).
#[derive(Clone)]
pub(crate) struct SafetyHeartbeatConfig {
/// Pre-rendered security policy summary text.
pub body: String,
/// Inject a heartbeat every `interval` tool iterations (0 = disabled).
pub interval: usize,
}
fn should_inject_safety_heartbeat(counter: usize, interval: usize) -> bool {
interval > 0 && counter > 0 && counter % interval == 0
}
/// Extract a short hint from tool call arguments for progress display.
@ -686,33 +700,37 @@ pub(crate) async fn run_tool_call_loop_with_non_cli_approval_context(
on_delta: Option<tokio::sync::mpsc::Sender<String>>,
hooks: Option<&crate::hooks::HookRunner>,
excluded_tools: &[String],
safety_heartbeat: Option<SafetyHeartbeatConfig>,
) -> Result<String> {
let reply_target = non_cli_approval_context
.as_ref()
.map(|ctx| ctx.reply_target.clone());
TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT
SAFETY_HEARTBEAT_CONFIG
.scope(
non_cli_approval_context,
TOOL_LOOP_REPLY_TARGET.scope(
reply_target,
run_tool_call_loop(
provider,
history,
tools_registry,
observer,
provider_name,
model,
temperature,
silent,
approval,
channel_name,
multimodal_config,
max_tool_iterations,
cancellation_token,
on_delta,
hooks,
excluded_tools,
safety_heartbeat,
TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT.scope(
non_cli_approval_context,
TOOL_LOOP_REPLY_TARGET.scope(
reply_target,
run_tool_call_loop(
provider,
history,
tools_registry,
observer,
provider_name,
model,
temperature,
silent,
approval,
channel_name,
multimodal_config,
max_tool_iterations,
cancellation_token,
on_delta,
hooks,
excluded_tools,
),
),
),
)
@ -787,6 +805,10 @@ pub(crate) async fn run_tool_call_loop(
.unwrap_or_default();
let mut loop_detector = LoopDetector::new(ld_config);
let mut loop_detection_prompt: Option<String> = None;
let heartbeat_config = SAFETY_HEARTBEAT_CONFIG
.try_with(Clone::clone)
.ok()
.flatten();
let bypass_non_cli_approval_for_turn =
approval.is_some_and(|mgr| channel_name != "cli" && mgr.consume_non_cli_allow_all_once());
if bypass_non_cli_approval_for_turn {
@ -834,6 +856,19 @@ pub(crate) async fn run_tool_call_loop(
request_messages.push(ChatMessage::user(prompt));
}
// ── Safety heartbeat: periodic security-constraint re-injection ──
if let Some(ref hb) = heartbeat_config {
if should_inject_safety_heartbeat(iteration, hb.interval) {
let reminder = format!(
"[Safety Heartbeat — round {}/{}]\n{}",
iteration + 1,
max_iterations,
hb.body
);
request_messages.push(ChatMessage::user(reminder));
}
}
// ── Progress: LLM thinking ────────────────────────────
if let Some(ref tx) = on_delta {
let phase = if iteration == 0 {
@ -2027,26 +2062,37 @@ pub async fn run(
ping_pong_cycles: config.agent.loop_detection_ping_pong_cycles,
failure_streak_threshold: config.agent.loop_detection_failure_streak,
};
let response = LOOP_DETECTION_CONFIG
let hb_cfg = if config.agent.safety_heartbeat_interval > 0 {
Some(SafetyHeartbeatConfig {
body: security.summary_for_heartbeat(),
interval: config.agent.safety_heartbeat_interval,
})
} else {
None
};
let response = SAFETY_HEARTBEAT_CONFIG
.scope(
ld_cfg,
run_tool_call_loop(
provider.as_ref(),
&mut history,
&tools_registry,
observer.as_ref(),
provider_name,
model_name,
temperature,
false,
approval_manager.as_ref(),
channel_name,
&config.multimodal,
config.agent.max_tool_iterations,
None,
None,
None,
&[],
hb_cfg,
LOOP_DETECTION_CONFIG.scope(
ld_cfg,
run_tool_call_loop(
provider.as_ref(),
&mut history,
&tools_registry,
observer.as_ref(),
provider_name,
model_name,
temperature,
false,
approval_manager.as_ref(),
channel_name,
&config.multimodal,
config.agent.max_tool_iterations,
None,
None,
None,
&[],
),
),
)
.await?;
@ -2060,6 +2106,7 @@ pub async fn run(
// Persistent conversation history across turns
let mut history = vec![ChatMessage::system(&system_prompt)];
let mut interactive_turn: usize = 0;
// Reusable readline editor for UTF-8 input support
let mut rl = Editor::with_config(
RlConfig::builder()
@ -2110,6 +2157,7 @@ pub async fn run(
rl.clear_history()?;
history.clear();
history.push(ChatMessage::system(&system_prompt));
interactive_turn = 0;
// Clear conversation and daily memory
let mut cleared = 0;
for category in [MemoryCategory::Conversation, MemoryCategory::Daily] {
@ -2155,32 +2203,57 @@ pub async fn run(
};
history.push(ChatMessage::user(&enriched));
interactive_turn += 1;
// Inject interactive safety heartbeat at configured turn intervals
if should_inject_safety_heartbeat(
interactive_turn,
config.agent.safety_heartbeat_turn_interval,
) {
let reminder = format!(
"[Safety Heartbeat — turn {}]\n{}",
interactive_turn,
security.summary_for_heartbeat()
);
history.push(ChatMessage::user(reminder));
}
let ld_cfg = LoopDetectionConfig {
no_progress_threshold: config.agent.loop_detection_no_progress_threshold,
ping_pong_cycles: config.agent.loop_detection_ping_pong_cycles,
failure_streak_threshold: config.agent.loop_detection_failure_streak,
};
let response = match LOOP_DETECTION_CONFIG
let hb_cfg = if config.agent.safety_heartbeat_interval > 0 {
Some(SafetyHeartbeatConfig {
body: security.summary_for_heartbeat(),
interval: config.agent.safety_heartbeat_interval,
})
} else {
None
};
let response = match SAFETY_HEARTBEAT_CONFIG
.scope(
ld_cfg,
run_tool_call_loop(
provider.as_ref(),
&mut history,
&tools_registry,
observer.as_ref(),
provider_name,
model_name,
temperature,
false,
approval_manager.as_ref(),
channel_name,
&config.multimodal,
config.agent.max_tool_iterations,
None,
None,
None,
&[],
hb_cfg,
LOOP_DETECTION_CONFIG.scope(
ld_cfg,
run_tool_call_loop(
provider.as_ref(),
&mut history,
&tools_registry,
observer.as_ref(),
provider_name,
model_name,
temperature,
false,
approval_manager.as_ref(),
channel_name,
&config.multimodal,
config.agent.max_tool_iterations,
None,
None,
None,
&[],
),
),
)
.await
@ -2436,19 +2509,31 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
ChatMessage::user(&enriched),
];
agent_turn(
provider.as_ref(),
&mut history,
&tools_registry,
observer.as_ref(),
provider_name,
&model_name,
config.default_temperature,
true,
&config.multimodal,
config.agent.max_tool_iterations,
)
.await
let hb_cfg = if config.agent.safety_heartbeat_interval > 0 {
Some(SafetyHeartbeatConfig {
body: security.summary_for_heartbeat(),
interval: config.agent.safety_heartbeat_interval,
})
} else {
None
};
SAFETY_HEARTBEAT_CONFIG
.scope(
hb_cfg,
agent_turn(
provider.as_ref(),
&mut history,
&tools_registry,
observer.as_ref(),
provider_name,
&model_name,
config.default_temperature,
true,
&config.multimodal,
config.agent.max_tool_iterations,
),
)
.await
}
#[cfg(test)]
@ -2543,6 +2628,36 @@ mod tests {
assert_eq!(feishu_args["delivery"]["to"], "oc_yyy");
}
#[test]
fn safety_heartbeat_interval_zero_disables_injection() {
for counter in [0, 1, 2, 10, 100] {
assert!(
!should_inject_safety_heartbeat(counter, 0),
"counter={counter} should not inject when interval=0"
);
}
}
#[test]
fn safety_heartbeat_interval_one_injects_every_non_initial_step() {
assert!(!should_inject_safety_heartbeat(0, 1));
for counter in 1..=6 {
assert!(
should_inject_safety_heartbeat(counter, 1),
"counter={counter} should inject when interval=1"
);
}
}
#[test]
fn safety_heartbeat_injects_only_on_exact_multiples() {
let interval = 3;
let injected: Vec<usize> = (0..=10)
.filter(|counter| should_inject_safety_heartbeat(*counter, interval))
.collect();
assert_eq!(injected, vec![3, 6, 9]);
}
use crate::memory::{Memory, MemoryCategory, SqliteMemory};
use crate::observability::NoopObserver;
use crate::providers::traits::ProviderCapabilities;
@ -3277,6 +3392,7 @@ mod tests {
None,
None,
&[],
None,
)
.await
.expect("tool loop should continue after non-cli approval");

View File

@ -70,6 +70,7 @@ pub use whatsapp_web::WhatsAppWebChannel;
use crate::agent::loop_::{
build_shell_policy_instructions, build_tool_instructions_from_specs,
run_tool_call_loop_with_non_cli_approval_context, scrub_credentials, NonCliApprovalContext,
SafetyHeartbeatConfig,
};
use crate::approval::{ApprovalManager, PendingApprovalError};
use crate::config::{Config, NonCliNaturalLanguageApprovalMode};
@ -287,6 +288,7 @@ struct ChannelRuntimeContext {
query_classification: crate::config::QueryClassificationConfig,
model_routes: Vec<crate::config::ModelRouteConfig>,
approval_manager: Arc<ApprovalManager>,
safety_heartbeat: Option<SafetyHeartbeatConfig>,
}
#[derive(Clone)]
@ -2205,10 +2207,8 @@ async fn handle_runtime_command_if_needed(
reply_target,
) {
Ok(req) => {
ctx.approval_manager.record_non_cli_pending_resolution(
&request_id,
ApprovalResponse::Yes,
);
ctx.approval_manager
.record_non_cli_pending_resolution(&request_id, ApprovalResponse::Yes);
let tool_name = req.tool_name;
let mut approval_message = if tool_name == APPROVAL_ALL_TOOLS_ONCE_TOKEN {
let remaining = ctx.approval_manager.grant_non_cli_allow_all_once();
@ -2327,10 +2327,8 @@ async fn handle_runtime_command_if_needed(
reply_target,
) {
Ok(req) => {
ctx.approval_manager.record_non_cli_pending_resolution(
&request_id,
ApprovalResponse::No,
);
ctx.approval_manager
.record_non_cli_pending_resolution(&request_id, ApprovalResponse::No);
runtime_trace::record_event(
"approval_request_rejected",
Some(source_channel),
@ -3378,6 +3376,7 @@ or tune thresholds in config.",
delta_tx,
ctx.hooks.as_deref(),
&excluded_tools_snapshot,
ctx.safety_heartbeat.clone(),
),
) => LlmExecutionResult::Completed(result),
};
@ -5232,6 +5231,14 @@ pub async fn start_channels(config: Config) -> Result<()> {
}
Arc::new(ApprovalManager::from_config(&autonomy))
},
safety_heartbeat: if config.agent.safety_heartbeat_interval > 0 {
Some(SafetyHeartbeatConfig {
body: security.summary_for_heartbeat(),
interval: config.agent.safety_heartbeat_interval,
})
} else {
None
},
});
run_message_dispatch_loop(rx, runtime_ctx, max_in_flight_messages).await;
@ -5570,6 +5577,7 @@ mod tests {
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: mock_price_approved_manager(),
safety_heartbeat: None,
};
assert!(compact_sender_history(&ctx, &sender));
@ -5622,6 +5630,7 @@ mod tests {
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: mock_price_approved_manager(),
safety_heartbeat: None,
};
append_sender_turn(&ctx, &sender, ChatMessage::user("hello"));
@ -5677,6 +5686,7 @@ mod tests {
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: mock_price_approved_manager(),
safety_heartbeat: None,
};
assert!(rollback_orphan_user_turn(&ctx, &sender, "pending"));
@ -6273,6 +6283,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: mock_price_approved_manager(),
safety_heartbeat: None,
});
process_channel_message(
@ -6348,6 +6359,7 @@ BTC is currently around $65,000 based on latest tool output."#
approval_manager: mock_price_approved_manager(),
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
safety_heartbeat: None,
});
process_channel_message(
@ -6410,6 +6422,7 @@ BTC is currently around $65,000 based on latest tool output."#
approval_manager: mock_price_approved_manager(),
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
safety_heartbeat: None,
});
process_channel_message(
@ -6486,6 +6499,7 @@ BTC is currently around $65,000 based on latest tool output."#
hooks: None,
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
safety_heartbeat: None,
});
process_channel_message(
@ -6561,6 +6575,7 @@ BTC is currently around $65,000 based on latest tool output."#
hooks: None,
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
safety_heartbeat: None,
});
process_channel_message(
@ -6628,6 +6643,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: mock_price_approved_manager(),
safety_heartbeat: None,
});
process_channel_message(
@ -6690,6 +6706,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: mock_price_approved_manager(),
safety_heartbeat: None,
});
process_channel_message(
@ -6761,6 +6778,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: mock_price_approved_manager(),
safety_heartbeat: None,
});
process_channel_message(
@ -6863,6 +6881,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)),
safety_heartbeat: None,
});
assert_eq!(
runtime_ctx
@ -7013,6 +7032,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)),
safety_heartbeat: None,
});
assert_eq!(
runtime_ctx
@ -7123,6 +7143,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager,
safety_heartbeat: None,
});
process_channel_message(
@ -7228,6 +7249,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager,
safety_heartbeat: None,
});
process_channel_message(
@ -7502,6 +7524,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)),
safety_heartbeat: None,
});
process_channel_message(
@ -7740,6 +7763,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)),
safety_heartbeat: None,
});
process_channel_message(
@ -7885,6 +7909,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)),
safety_heartbeat: None,
});
process_channel_message(
@ -8000,6 +8025,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)),
safety_heartbeat: None,
});
process_channel_message(
@ -8095,6 +8121,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)),
safety_heartbeat: None,
});
process_channel_message(
@ -8209,6 +8236,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)),
safety_heartbeat: None,
});
process_channel_message(
@ -8326,6 +8354,7 @@ BTC is currently around $65,000 based on latest tool output."#
approval_manager: Arc::new(ApprovalManager::from_config(
&crate::config::AutonomyConfig::default(),
)),
safety_heartbeat: None,
});
process_channel_message(
@ -8402,6 +8431,7 @@ BTC is currently around $65,000 based on latest tool output."#
approval_manager: Arc::new(ApprovalManager::from_config(
&crate::config::AutonomyConfig::default(),
)),
safety_heartbeat: None,
});
process_channel_message(
@ -8495,6 +8525,7 @@ BTC is currently around $65,000 based on latest tool output."#
approval_manager: Arc::new(ApprovalManager::from_config(
&crate::config::AutonomyConfig::default(),
)),
safety_heartbeat: None,
});
process_channel_message(
@ -8649,6 +8680,7 @@ BTC is currently around $65,000 based on latest tool output."#
approval_manager: Arc::new(ApprovalManager::from_config(
&crate::config::AutonomyConfig::default(),
)),
safety_heartbeat: None,
});
maybe_apply_runtime_config_update(runtime_ctx.as_ref())
@ -8762,6 +8794,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: mock_price_approved_manager(),
safety_heartbeat: None,
});
process_channel_message(
@ -8825,6 +8858,7 @@ BTC is currently around $65,000 based on latest tool output."#
query_classification: crate::config::QueryClassificationConfig::default(),
model_routes: Vec::new(),
approval_manager: mock_price_approved_manager(),
safety_heartbeat: None,
});
process_channel_message(
@ -9002,6 +9036,7 @@ BTC is currently around $65,000 based on latest tool output."#
approval_manager: Arc::new(ApprovalManager::from_config(
&crate::config::AutonomyConfig::default(),
)),
safety_heartbeat: None,
});
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(4);
@ -9087,6 +9122,7 @@ BTC is currently around $65,000 based on latest tool output."#
approval_manager: Arc::new(ApprovalManager::from_config(
&crate::config::AutonomyConfig::default(),
)),
safety_heartbeat: None,
});
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
@ -9184,6 +9220,7 @@ BTC is currently around $65,000 based on latest tool output."#
approval_manager: Arc::new(ApprovalManager::from_config(
&crate::config::AutonomyConfig::default(),
)),
safety_heartbeat: None,
});
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
@ -9263,6 +9300,7 @@ BTC is currently around $65,000 based on latest tool output."#
approval_manager: Arc::new(ApprovalManager::from_config(
&crate::config::AutonomyConfig::default(),
)),
safety_heartbeat: None,
});
process_channel_message(
@ -9327,6 +9365,7 @@ BTC is currently around $65,000 based on latest tool output."#
approval_manager: Arc::new(ApprovalManager::from_config(
&crate::config::AutonomyConfig::default(),
)),
safety_heartbeat: None,
});
process_channel_message(
@ -9876,6 +9915,7 @@ BTC is currently around $65,000 based on latest tool output."#
approval_manager: Arc::new(ApprovalManager::from_config(
&crate::config::AutonomyConfig::default(),
)),
safety_heartbeat: None,
});
process_channel_message(
@ -9966,6 +10006,7 @@ BTC is currently around $65,000 based on latest tool output."#
approval_manager: Arc::new(ApprovalManager::from_config(
&crate::config::AutonomyConfig::default(),
)),
safety_heartbeat: None,
});
process_channel_message(
@ -10060,6 +10101,7 @@ BTC is currently around $65,000 based on latest tool output."#
approval_manager: Arc::new(ApprovalManager::from_config(
&crate::config::AutonomyConfig::default(),
)),
safety_heartbeat: None,
});
process_channel_message(
@ -10840,6 +10882,7 @@ BTC is currently around $65,000 based on latest tool output."#;
approval_manager: Arc::new(ApprovalManager::from_config(
&crate::config::AutonomyConfig::default(),
)),
safety_heartbeat: None,
});
// Simulate a photo attachment message with [IMAGE:] marker.
@ -10911,6 +10954,7 @@ BTC is currently around $65,000 based on latest tool output."#;
approval_manager: Arc::new(ApprovalManager::from_config(
&crate::config::AutonomyConfig::default(),
)),
safety_heartbeat: None,
});
process_channel_message(

View File

@ -748,6 +748,20 @@ pub struct AgentConfig {
/// Set to `0` to disable. Default: `3`.
#[serde(default = "default_loop_detection_failure_streak")]
pub loop_detection_failure_streak: usize,
/// Safety heartbeat injection interval inside `run_tool_call_loop`.
/// Injects a security-constraint reminder every N tool iterations.
/// Set to `0` to disable. Default: `5`.
/// Compatibility/rollback: omit/remove this key to use default (`5`), or set
/// to `0` for explicit disable.
#[serde(default = "default_safety_heartbeat_interval")]
pub safety_heartbeat_interval: usize,
/// Safety heartbeat injection interval for interactive sessions.
/// Injects a security-constraint reminder every N conversation turns.
/// Set to `0` to disable. Default: `10`.
/// Compatibility/rollback: omit/remove this key to use default (`10`), or
/// set to `0` for explicit disable.
#[serde(default = "default_safety_heartbeat_turn_interval")]
pub safety_heartbeat_turn_interval: usize,
}
fn default_agent_max_tool_iterations() -> usize {
@ -774,6 +788,14 @@ fn default_loop_detection_failure_streak() -> usize {
3
}
fn default_safety_heartbeat_interval() -> usize {
5
}
fn default_safety_heartbeat_turn_interval() -> usize {
10
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
@ -785,6 +807,8 @@ impl Default for AgentConfig {
loop_detection_no_progress_threshold: default_loop_detection_no_progress_threshold(),
loop_detection_ping_pong_cycles: default_loop_detection_ping_pong_cycles(),
loop_detection_failure_streak: default_loop_detection_failure_streak(),
safety_heartbeat_interval: default_safety_heartbeat_interval(),
safety_heartbeat_turn_interval: default_safety_heartbeat_turn_interval(),
}
}
}

View File

@ -1073,6 +1073,69 @@ impl SecurityPolicy {
}
/// Build from config sections
/// Produce a concise security-constraint summary suitable for periodic
/// re-injection into the conversation (safety heartbeat).
///
/// The output is intentionally short (~100-150 tokens) so the token
/// overhead per heartbeat is negligible.
pub fn summary_for_heartbeat(&self) -> String {
let autonomy_label = match self.autonomy {
AutonomyLevel::ReadOnly => "read_only — side-effecting actions are blocked",
AutonomyLevel::Supervised => "supervised — destructive actions require approval",
AutonomyLevel::Full => "full — autonomous execution within policy bounds",
};
let workspace = self.workspace_dir.display();
let ws_only = self.workspace_only;
let forbidden_preview: String = {
let shown: Vec<&str> = self
.forbidden_paths
.iter()
.take(8)
.map(String::as_str)
.collect();
let remaining = self.forbidden_paths.len().saturating_sub(8);
if remaining > 0 {
format!("{} (+ {} more)", shown.join(", "), remaining)
} else {
shown.join(", ")
}
};
let commands_preview: String = {
let shown: Vec<&str> = self
.allowed_commands
.iter()
.take(8)
.map(String::as_str)
.collect();
let remaining = self.allowed_commands.len().saturating_sub(8);
if remaining > 0 {
format!("{} (+ {} more rejected)", shown.join(", "), remaining)
} else if shown.is_empty() {
"none (all rejected)".to_string()
} else {
format!("{} (others rejected)", shown.join(", "))
}
};
let high_risk = if self.block_high_risk_commands {
"blocked"
} else {
"allowed (caution)"
};
format!(
"- Autonomy: {autonomy_label}\n\
- Workspace: {workspace} (workspace_only: {ws_only})\n\
- Forbidden paths: {forbidden_preview}\n\
- Allowed commands: {commands_preview}\n\
- High-risk commands: {high_risk}\n\
- Do not exfiltrate data, bypass approval, or run destructive commands without asking."
)
}
pub fn from_config(
autonomy_config: &crate::config::AutonomyConfig,
workspace_dir: &Path,
@ -2103,6 +2166,53 @@ mod tests {
assert!(!policy.is_rate_limited());
}
// ── summary_for_heartbeat ──────────────────────────────
#[test]
fn summary_for_heartbeat_contains_key_fields() {
let policy = default_policy();
let summary = policy.summary_for_heartbeat();
assert!(summary.contains("Autonomy:"));
assert!(summary.contains("supervised"));
assert!(summary.contains("Workspace:"));
assert!(summary.contains("workspace_only: true"));
assert!(summary.contains("Forbidden paths:"));
assert!(summary.contains("/etc"));
assert!(summary.contains("Allowed commands:"));
assert!(summary.contains("git"));
assert!(summary.contains("High-risk commands: blocked"));
assert!(summary.contains("Do not exfiltrate data"));
}
#[test]
fn summary_for_heartbeat_truncates_long_lists() {
let policy = SecurityPolicy {
forbidden_paths: (0..15).map(|i| format!("/path_{i}")).collect(),
allowed_commands: (0..12).map(|i| format!("cmd_{i}")).collect(),
..SecurityPolicy::default()
};
let summary = policy.summary_for_heartbeat();
// Only first 8 shown, remainder counted
assert!(summary.contains("+ 7 more"));
assert!(summary.contains("+ 4 more rejected"));
}
#[test]
fn summary_for_heartbeat_full_autonomy() {
let policy = full_policy();
let summary = policy.summary_for_heartbeat();
assert!(summary.contains("full"));
assert!(summary.contains("autonomous execution"));
}
#[test]
fn summary_for_heartbeat_readonly_autonomy() {
let policy = readonly_policy();
let summary = policy.summary_for_heartbeat();
assert!(summary.contains("read_only"));
assert!(summary.contains("side-effecting actions are blocked"));
}
// ══════════════════════════════════════════════════════════
// SECURITY CHECKLIST TESTS
// Checklist: gateway not public, pairing required,