fix: run Docker container as non-root user (closes #34)

- Switch to gcr.io/distroless/cc-debian12:nonroot
- Add explicit USER 65534:65534 directive
- Add Docker security CI job verifying non-root UID, :nonroot base, and USER directive
- Document CIS Docker Benchmark compliance in SECURITY.md
- Add tests and edge cases for container security
This commit is contained in:
argenis de la rosa 2026-02-14 13:16:33 -05:00
parent cc08f4bfff
commit 76074cb789
14 changed files with 2270 additions and 168 deletions

View file

@ -16,7 +16,8 @@ pub use telegram::TelegramChannel;
pub use traits::Channel;
pub use whatsapp::WhatsAppChannel;
use crate::config::Config;
use crate::config::{Config, IdentityConfig};
use crate::identity::aieos::{parse_aieos_json, AieosEntity};
use crate::memory::{self, Memory};
use crate::providers::{self, Provider};
use anyhow::Result;
@ -188,6 +189,195 @@ pub fn build_system_prompt(
}
}
/// Build a system prompt with AIEOS identity support.
///
/// This is the identity-agnostic version that supports both:
/// - **OpenClaw** (default): Markdown files (IDENTITY.md, SOUL.md, etc.)
/// - **AIEOS**: JSON-based portable identity (aieos.org v1.1)
///
/// When `identity.format = "aieos"`, the AIEOS identity is loaded and injected
/// instead of the traditional markdown bootstrap files.
pub fn build_system_prompt_with_identity(
workspace_dir: &std::path::Path,
model_name: &str,
tools: &[(&str, &str)],
skills: &[crate::skills::Skill],
identity_config: &IdentityConfig,
) -> String {
use std::fmt::Write;
let mut prompt = String::with_capacity(8192);
// ── 1. Tooling ──────────────────────────────────────────────
if !tools.is_empty() {
prompt.push_str("## Tools\n\n");
prompt.push_str("You have access to the following tools:\n\n");
for (name, desc) in tools {
let _ = writeln!(prompt, "- **{name}**: {desc}");
}
prompt.push('\n');
}
// ── 2. Safety ───────────────────────────────────────────────
prompt.push_str("## Safety\n\n");
prompt.push_str(
"- Do not exfiltrate private data.\n\
- Do not run destructive commands without asking.\n\
- Do not bypass oversight or approval mechanisms.\n\
- Prefer `trash` over `rm` (recoverable beats gone forever).\n\
- When in doubt, ask before acting externally.\n\n",
);
// ── 3. Skills (compact list — load on-demand) ───────────────
if !skills.is_empty() {
prompt.push_str("## Available Skills\n\n");
prompt.push_str(
"Skills are loaded on demand. Use `read` on the skill path to get full instructions.\n\n",
);
prompt.push_str("<available_skills>\n");
for skill in skills {
let _ = writeln!(prompt, " <skill>");
let _ = writeln!(prompt, " <name>{}</name>", skill.name);
let _ = writeln!(
prompt,
" <description>{}</description>",
skill.description
);
let location = workspace_dir
.join("skills")
.join(&skill.name)
.join("SKILL.md");
let _ = writeln!(prompt, " <location>{}</location>", location.display());
let _ = writeln!(prompt, " </skill>");
}
prompt.push_str("</available_skills>\n\n");
}
// ── 4. Workspace ────────────────────────────────────────────
let _ = writeln!(
prompt,
"## Workspace\n\nWorking directory: `{}`\n",
workspace_dir.display()
);
// ── 5. Identity (AIEOS or OpenClaw) ─────────────────────────
if identity_config.format.eq_ignore_ascii_case("aieos") {
// Try to load AIEOS identity
if let Some(aieos_entity) = load_aieos_from_config(workspace_dir, identity_config) {
prompt.push_str(&aieos_entity.to_system_prompt());
} else {
// Fallback to OpenClaw if AIEOS loading fails
tracing::warn!("AIEOS identity configured but failed to load; falling back to OpenClaw");
inject_openclaw_identity(&mut prompt, workspace_dir);
}
} else {
// Default: OpenClaw markdown files
inject_openclaw_identity(&mut prompt, workspace_dir);
}
// ── 6. Date & Time ──────────────────────────────────────────
let now = chrono::Local::now();
let tz = now.format("%Z").to_string();
let _ = writeln!(prompt, "## Current Date & Time\n\nTimezone: {tz}\n");
// ── 7. Runtime ──────────────────────────────────────────────
let host =
hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
let _ = writeln!(
prompt,
"## Runtime\n\nHost: {host} | OS: {} | Model: {model_name}\n",
std::env::consts::OS,
);
if prompt.is_empty() {
"You are ZeroClaw, a fast and efficient AI assistant built in Rust. Be helpful, concise, and direct.".to_string()
} else {
prompt
}
}
/// Load AIEOS entity from config (file path or inline JSON)
fn load_aieos_from_config(
workspace_dir: &std::path::Path,
identity_config: &IdentityConfig,
) -> Option<AieosEntity> {
// Try inline JSON first
if let Some(ref inline_json) = identity_config.aieos_inline {
if !inline_json.is_empty() {
match parse_aieos_json(inline_json) {
Ok(entity) => {
tracing::info!("Loaded AIEOS identity from inline JSON: {}", entity.display_name());
return Some(entity);
}
Err(e) => {
tracing::error!("Failed to parse inline AIEOS JSON: {e}");
}
}
}
}
// Try file path
if let Some(ref path_str) = identity_config.aieos_path {
if !path_str.is_empty() {
let path = if std::path::Path::new(path_str).is_absolute() {
std::path::PathBuf::from(path_str)
} else {
workspace_dir.join(path_str)
};
match std::fs::read_to_string(&path) {
Ok(content) => match parse_aieos_json(&content) {
Ok(entity) => {
tracing::info!(
"Loaded AIEOS identity from {}: {}",
path.display(),
entity.display_name()
);
return Some(entity);
}
Err(e) => {
tracing::error!("Failed to parse AIEOS file {}: {e}", path.display());
}
},
Err(e) => {
tracing::error!("Failed to read AIEOS file {}: {e}", path.display());
}
}
}
}
None
}
/// Inject OpenClaw (markdown) identity files into the prompt
fn inject_openclaw_identity(prompt: &mut String, workspace_dir: &std::path::Path) {
use std::fmt::Write;
prompt.push_str("## Project Context\n\n");
prompt.push_str("The following workspace files define your identity, behavior, and context.\n\n");
let bootstrap_files = [
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
];
for filename in &bootstrap_files {
inject_workspace_file(prompt, workspace_dir, filename);
}
// BOOTSTRAP.md — only if it exists (first-run ritual)
let bootstrap_path = workspace_dir.join("BOOTSTRAP.md");
if bootstrap_path.exists() {
inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md");
}
// MEMORY.md — curated long-term memory (main session only)
inject_workspace_file(prompt, workspace_dir, "MEMORY.md");
}
/// Inject a single workspace file into the prompt with truncation and missing-file markers.
fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, filename: &str) {
use std::fmt::Write;

View file

@ -34,9 +34,7 @@ impl WhatsAppChannel {
/// Check if a phone number is allowed (E.164 format: +1234567890)
fn is_number_allowed(&self, phone: &str) -> bool {
self.allowed_numbers
.iter()
.any(|n| n == "*" || n == phone)
self.allowed_numbers.iter().any(|n| n == "*" || n == phone)
}
/// Get the verify token for webhook verification
@ -45,10 +43,7 @@ impl WhatsAppChannel {
}
/// Parse an incoming webhook payload from Meta and extract messages
pub fn parse_webhook_payload(
&self,
payload: &serde_json::Value,
) -> Vec<ChannelMessage> {
pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
let mut messages = Vec::new();
// WhatsApp Cloud API webhook structure:
@ -200,10 +195,7 @@ impl Channel for WhatsAppChannel {
async fn health_check(&self) -> bool {
// Check if we can reach the WhatsApp API
let url = format!(
"https://graph.facebook.com/v18.0/{}",
self.phone_number_id
);
let url = format!("https://graph.facebook.com/v18.0/{}", self.phone_number_id);
self.client
.get(&url)
@ -249,12 +241,7 @@ mod tests {
#[test]
fn whatsapp_number_allowed_wildcard() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
assert!(ch.is_number_allowed("+1234567890"));
assert!(ch.is_number_allowed("+9999999999"));
}
@ -335,12 +322,7 @@ mod tests {
#[test]
fn whatsapp_parse_non_text_message_skipped() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -362,12 +344,7 @@ mod tests {
#[test]
fn whatsapp_parse_multiple_messages() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -418,12 +395,7 @@ mod tests {
#[test]
fn whatsapp_empty_text_skipped() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -535,12 +507,7 @@ mod tests {
#[test]
fn whatsapp_parse_missing_from_field() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -560,12 +527,7 @@ mod tests {
#[test]
fn whatsapp_parse_missing_text_body() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -581,17 +543,15 @@ mod tests {
}]
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty(), "Messages with empty text object should be skipped");
assert!(
msgs.is_empty(),
"Messages with empty text object should be skipped"
);
}
#[test]
fn whatsapp_parse_null_text_body() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -612,12 +572,7 @@ mod tests {
#[test]
fn whatsapp_parse_invalid_timestamp_uses_current() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -640,12 +595,7 @@ mod tests {
#[test]
fn whatsapp_parse_missing_timestamp_uses_current() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -666,12 +616,7 @@ mod tests {
#[test]
fn whatsapp_parse_multiple_entries() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [
{
@ -708,12 +653,7 @@ mod tests {
#[test]
fn whatsapp_parse_multiple_changes() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [
@ -769,12 +709,7 @@ mod tests {
#[test]
fn whatsapp_parse_audio_message_skipped() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -795,12 +730,7 @@ mod tests {
#[test]
fn whatsapp_parse_video_message_skipped() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -821,12 +751,7 @@ mod tests {
#[test]
fn whatsapp_parse_document_message_skipped() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -847,12 +772,7 @@ mod tests {
#[test]
fn whatsapp_parse_sticker_message_skipped() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -873,12 +793,7 @@ mod tests {
#[test]
fn whatsapp_parse_location_message_skipped() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -899,12 +814,7 @@ mod tests {
#[test]
fn whatsapp_parse_contacts_message_skipped() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -925,12 +835,7 @@ mod tests {
#[test]
fn whatsapp_parse_reaction_message_skipped() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -978,12 +883,7 @@ mod tests {
#[test]
fn whatsapp_parse_unicode_message() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -1005,12 +905,7 @@ mod tests {
#[test]
fn whatsapp_parse_very_long_message() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let long_text = "A".repeat(10_000);
let payload = serde_json::json!({
"entry": [{
@ -1033,12 +928,7 @@ mod tests {
#[test]
fn whatsapp_parse_whitespace_only_message_skipped() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -1065,7 +955,11 @@ mod tests {
"tok".into(),
"123".into(),
"ver".into(),
vec!["+1111111111".into(), "+2222222222".into(), "+3333333333".into()],
vec![
"+1111111111".into(),
"+2222222222".into(),
"+3333333333".into(),
],
);
assert!(ch.is_number_allowed("+1111111111"));
assert!(ch.is_number_allowed("+2222222222"));
@ -1169,12 +1063,7 @@ mod tests {
#[test]
fn whatsapp_parse_newlines_preserved() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -1196,12 +1085,7 @@ mod tests {
#[test]
fn whatsapp_parse_special_characters() {
let ch = WhatsAppChannel::new(
"tok".into(),
"123".into(),
"ver".into(),
vec!["*".into()],
);
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
@ -1218,6 +1102,9 @@ mod tests {
});
let msgs = ch.parse_webhook_payload(&payload);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].content, "<script>alert('xss')</script> & \"quotes\" 'apostrophe'");
assert_eq!(
msgs[0].content,
"<script>alert('xss')</script> & \"quotes\" 'apostrophe'"
);
}
}