feat: add AIEOS identity support and harden cron scheduler security
- Add IdentityConfig with format=openclaw|aieos, aieos_path, and aieos_inline - Implement AIEOS v1.1 JSON parser and system prompt injection - Add build_system_prompt_with_identity() supporting both OpenClaw markdown and AIEOS JSON - Harden cron scheduler with SecurityPolicy checks (command allowlist, forbidden path arguments) - Skip retries on deterministic security policy violations - Add comprehensive tests for AIEOS config and cron security edge cases - Update README with AIEOS documentation and schema overview - Add .dockerignore tests for build context security validation
This commit is contained in:
parent
76074cb789
commit
acea042bdb
7 changed files with 790 additions and 22 deletions
|
|
@ -1,5 +1,6 @@
|
|||
use crate::config::Config;
|
||||
use crate::cron::{due_jobs, reschedule_after_run, CronJob};
|
||||
use crate::security::SecurityPolicy;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use tokio::process::Command;
|
||||
|
|
@ -10,6 +11,7 @@ const MIN_POLL_SECONDS: u64 = 5;
|
|||
pub async fn run(config: Config) -> Result<()> {
|
||||
let poll_secs = config.reliability.scheduler_poll_secs.max(MIN_POLL_SECONDS);
|
||||
let mut interval = time::interval(Duration::from_secs(poll_secs));
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
|
||||
crate::health::mark_component_ok("scheduler");
|
||||
|
||||
|
|
@ -27,7 +29,7 @@ pub async fn run(config: Config) -> Result<()> {
|
|||
|
||||
for job in jobs {
|
||||
crate::health::mark_component_ok("scheduler");
|
||||
let (success, output) = execute_job_with_retry(&config, &job).await;
|
||||
let (success, output) = execute_job_with_retry(&config, &security, &job).await;
|
||||
|
||||
if !success {
|
||||
crate::health::mark_component_error("scheduler", format!("job {} failed", job.id));
|
||||
|
|
@ -41,19 +43,28 @@ pub async fn run(config: Config) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
async fn execute_job_with_retry(config: &Config, job: &CronJob) -> (bool, String) {
|
||||
async fn execute_job_with_retry(
|
||||
config: &Config,
|
||||
security: &SecurityPolicy,
|
||||
job: &CronJob,
|
||||
) -> (bool, String) {
|
||||
let mut last_output = String::new();
|
||||
let retries = config.reliability.scheduler_retries;
|
||||
let mut backoff_ms = config.reliability.provider_backoff_ms.max(200);
|
||||
|
||||
for attempt in 0..=retries {
|
||||
let (success, output) = run_job_command(config, job).await;
|
||||
let (success, output) = run_job_command(config, security, job).await;
|
||||
last_output = output;
|
||||
|
||||
if success {
|
||||
return (true, last_output);
|
||||
}
|
||||
|
||||
if last_output.starts_with("blocked by security policy:") {
|
||||
// Deterministic policy violations are not retryable.
|
||||
return (false, last_output);
|
||||
}
|
||||
|
||||
if attempt < retries {
|
||||
let jitter_ms = (Utc::now().timestamp_subsec_millis() % 250) as u64;
|
||||
time::sleep(Duration::from_millis(backoff_ms + jitter_ms)).await;
|
||||
|
|
@ -64,7 +75,86 @@ async fn execute_job_with_retry(config: &Config, job: &CronJob) -> (bool, String
|
|||
(false, last_output)
|
||||
}
|
||||
|
||||
async fn run_job_command(config: &Config, job: &CronJob) -> (bool, String) {
|
||||
fn is_env_assignment(word: &str) -> bool {
|
||||
word.contains('=')
|
||||
&& word
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
}
|
||||
|
||||
fn strip_wrapping_quotes(token: &str) -> &str {
|
||||
token.trim_matches(|c| c == '"' || c == '\'')
|
||||
}
|
||||
|
||||
fn forbidden_path_argument(security: &SecurityPolicy, command: &str) -> Option<String> {
|
||||
let mut normalized = command.to_string();
|
||||
for sep in ["&&", "||"] {
|
||||
normalized = normalized.replace(sep, "\x00");
|
||||
}
|
||||
for sep in ['\n', ';', '|'] {
|
||||
normalized = normalized.replace(sep, "\x00");
|
||||
}
|
||||
|
||||
for segment in normalized.split('\x00') {
|
||||
let tokens: Vec<&str> = segment.split_whitespace().collect();
|
||||
if tokens.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip leading env assignments and executable token.
|
||||
let mut idx = 0;
|
||||
while idx < tokens.len() && is_env_assignment(tokens[idx]) {
|
||||
idx += 1;
|
||||
}
|
||||
if idx >= tokens.len() {
|
||||
continue;
|
||||
}
|
||||
idx += 1;
|
||||
|
||||
for token in &tokens[idx..] {
|
||||
let candidate = strip_wrapping_quotes(token);
|
||||
if candidate.is_empty() || candidate.starts_with('-') || candidate.contains("://") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let looks_like_path = candidate.starts_with('/')
|
||||
|| candidate.starts_with("./")
|
||||
|| candidate.starts_with("../")
|
||||
|| candidate.starts_with("~/")
|
||||
|| candidate.contains('/');
|
||||
|
||||
if looks_like_path && !security.is_path_allowed(candidate) {
|
||||
return Some(candidate.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn run_job_command(
|
||||
config: &Config,
|
||||
security: &SecurityPolicy,
|
||||
job: &CronJob,
|
||||
) -> (bool, String) {
|
||||
if !security.is_command_allowed(&job.command) {
|
||||
return (
|
||||
false,
|
||||
format!(
|
||||
"blocked by security policy: command not allowed: {}",
|
||||
job.command
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(path) = forbidden_path_argument(security, &job.command) {
|
||||
return (
|
||||
false,
|
||||
format!("blocked by security policy: forbidden path argument: {path}"),
|
||||
);
|
||||
}
|
||||
|
||||
let output = Command::new("sh")
|
||||
.arg("-lc")
|
||||
.arg(&job.command)
|
||||
|
|
@ -92,6 +182,7 @@ async fn run_job_command(config: &Config, job: &CronJob) -> (bool, String) {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::security::SecurityPolicy;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_config(tmp: &TempDir) -> Config {
|
||||
|
|
@ -118,8 +209,9 @@ mod tests {
|
|||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let job = test_job("echo scheduler-ok");
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
|
||||
let (success, output) = run_job_command(&config, &job).await;
|
||||
let (success, output) = run_job_command(&config, &security, &job).await;
|
||||
assert!(success);
|
||||
assert!(output.contains("scheduler-ok"));
|
||||
assert!(output.contains("status=exit status: 0"));
|
||||
|
|
@ -129,12 +221,42 @@ mod tests {
|
|||
async fn run_job_command_failure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let job = test_job("echo scheduler-fail 1>&2; exit 7");
|
||||
let job = test_job("ls definitely_missing_file_for_scheduler_test");
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
|
||||
let (success, output) = run_job_command(&config, &job).await;
|
||||
let (success, output) = run_job_command(&config, &security, &job).await;
|
||||
assert!(!success);
|
||||
assert!(output.contains("scheduler-fail"));
|
||||
assert!(output.contains("status=exit status: 7"));
|
||||
assert!(output.contains("definitely_missing_file_for_scheduler_test"));
|
||||
assert!(output.contains("status=exit status:"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_job_command_blocks_disallowed_command() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut config = test_config(&tmp);
|
||||
config.autonomy.allowed_commands = vec!["echo".into()];
|
||||
let job = test_job("curl https://evil.example");
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
|
||||
let (success, output) = run_job_command(&config, &security, &job).await;
|
||||
assert!(!success);
|
||||
assert!(output.contains("blocked by security policy"));
|
||||
assert!(output.contains("command not allowed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_job_command_blocks_forbidden_path_argument() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut config = test_config(&tmp);
|
||||
config.autonomy.allowed_commands = vec!["cat".into()];
|
||||
let job = test_job("cat /etc/passwd");
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
|
||||
let (success, output) = run_job_command(&config, &security, &job).await;
|
||||
assert!(!success);
|
||||
assert!(output.contains("blocked by security policy"));
|
||||
assert!(output.contains("forbidden path argument"));
|
||||
assert!(output.contains("/etc/passwd"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -143,12 +265,17 @@ mod tests {
|
|||
let mut config = test_config(&tmp);
|
||||
config.reliability.scheduler_retries = 1;
|
||||
config.reliability.provider_backoff_ms = 1;
|
||||
config.autonomy.allowed_commands = vec!["sh".into()];
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
|
||||
let job = test_job(
|
||||
"if [ -f retry-ok.flag ]; then echo recovered; exit 0; else touch retry-ok.flag; echo first-fail 1>&2; exit 1; fi",
|
||||
);
|
||||
std::fs::write(
|
||||
config.workspace_dir.join("retry-once.sh"),
|
||||
"#!/bin/sh\nif [ -f retry-ok.flag ]; then\n echo recovered\n exit 0\nfi\ntouch retry-ok.flag\nexit 1\n",
|
||||
)
|
||||
.unwrap();
|
||||
let job = test_job("sh ./retry-once.sh");
|
||||
|
||||
let (success, output) = execute_job_with_retry(&config, &job).await;
|
||||
let (success, output) = execute_job_with_retry(&config, &security, &job).await;
|
||||
assert!(success);
|
||||
assert!(output.contains("recovered"));
|
||||
}
|
||||
|
|
@ -159,11 +286,12 @@ mod tests {
|
|||
let mut config = test_config(&tmp);
|
||||
config.reliability.scheduler_retries = 1;
|
||||
config.reliability.provider_backoff_ms = 1;
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
|
||||
let job = test_job("echo still-bad 1>&2; exit 1");
|
||||
let job = test_job("ls always_missing_for_retry_test");
|
||||
|
||||
let (success, output) = execute_job_with_retry(&config, &job).await;
|
||||
let (success, output) = execute_job_with_retry(&config, &security, &job).await;
|
||||
assert!(!success);
|
||||
assert!(output.contains("still-bad"));
|
||||
assert!(output.contains("always_missing_for_retry_test"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue