feat(wave-1): 适配器框架与安全基座(schema校验/dry-run/exec沙箱/密钥库/日志脱敏/字段拆分)
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
//! 执行层错误类型(中文,不含密钥明文)
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ExecError {
|
||||
/// 参数含 shell 元字符等危险输入
|
||||
DangerousInput(String),
|
||||
/// 参数槽位白名单校验失败
|
||||
InvalidSlot(String),
|
||||
/// 工作目录越界
|
||||
CwdNotAllowed(String),
|
||||
/// 进程启动失败
|
||||
Spawn(String),
|
||||
/// 非零退出码
|
||||
NonZeroExit(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ExecError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ExecError::DangerousInput(m) => write!(f, "危险输入: {m}"),
|
||||
ExecError::InvalidSlot(m) => write!(f, "参数校验失败: {m}"),
|
||||
ExecError::CwdNotAllowed(m) => write!(f, "工作目录越界: {m}"),
|
||||
ExecError::Spawn(m) => write!(f, "进程启动失败: {m}"),
|
||||
ExecError::NonZeroExit(m) => write!(f, "命令非零退出: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ExecError {}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! 仅 argv 数组的安全进程执行(架构 §4.2)
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
use crate::error::ExecError;
|
||||
|
||||
/// shell 注入元字符(argv 场景,架构 §4.2)。`\` `/` 属路径分隔符,允许出现在
|
||||
/// 可执行文件路径或路径参数里(argv 不经 shell,无转义语义)。
|
||||
const SHELL_INJECT_CHARS: &[char] = &['|', '&', ';', '$', '>', '<', '(', ')', '`'];
|
||||
|
||||
/// 返回首个 shell 注入元字符。
|
||||
pub fn first_metachar(s: &str) -> Option<char> {
|
||||
s.chars().find(|c| SHELL_INJECT_CHARS.contains(c))
|
||||
}
|
||||
|
||||
/// 校验可执行文件名:非空、不含 shell 注入元字符、不以 `-` 开头。
|
||||
fn validate_executable(prog: &str) -> Result<(), ExecError> {
|
||||
if prog.is_empty() {
|
||||
return Err(ExecError::InvalidSlot("可执行文件名不能为空".into()));
|
||||
}
|
||||
if let Some(c) = first_metachar(prog) {
|
||||
return Err(ExecError::DangerousInput(format!("可执行文件名含 shell 元字符 {c:?}: {prog}")));
|
||||
}
|
||||
if prog.starts_with('-') {
|
||||
return Err(ExecError::InvalidSlot(format!("可执行文件名不能以 - 开头: {prog}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验 argv 数组:可执行文件 + 每个参数均不含 shell 元字符。
|
||||
/// 用户输入只能作为已校验参数槽位(枚举/路径/版本号)传入,禁止拼接。
|
||||
pub fn validate_argv(prog: &str, args: &[String]) -> Result<(), ExecError> {
|
||||
validate_executable(prog)?;
|
||||
for arg in args {
|
||||
if let Some(c) = first_metachar(arg) {
|
||||
return Err(ExecError::DangerousInput(format!("参数含 shell 元字符 {c:?}: {arg}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 路径规范化:优先 canonicalize(消 .. 与符号链接),失败则转绝对路径。
|
||||
fn normalize(p: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(p).unwrap_or_else(|_| {
|
||||
if p.is_absolute() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join(p)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 工作目录限制(架构 §4.2 第 3 条):cwd 必须落在某个允许的根目录之内。
|
||||
pub fn ensure_cwd_within(cwd: &Path, allowed_roots: &[PathBuf]) -> Result<(), ExecError> {
|
||||
if allowed_roots.is_empty() {
|
||||
return Err(ExecError::CwdNotAllowed("未配置允许的工作目录根".into()));
|
||||
}
|
||||
let cwd_norm = normalize(cwd);
|
||||
for root in allowed_roots {
|
||||
let root_norm = normalize(root);
|
||||
if cwd_norm.starts_with(&root_norm) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(ExecError::CwdNotAllowed(format!(
|
||||
"工作目录「{}」超出允许范围",
|
||||
cwd.display()
|
||||
)))
|
||||
}
|
||||
|
||||
/// 仅 argv 执行:`Command::new(prog).args(args)`,禁止 shell 拼接 / 管道 / 重定向。
|
||||
/// cwd 为 None 时继承当前进程目录。
|
||||
pub fn spawn(prog: &str, args: &[String], cwd: Option<&Path>) -> Result<Output, ExecError> {
|
||||
validate_argv(prog, args)?;
|
||||
let mut cmd = Command::new(prog);
|
||||
cmd.args(args);
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
cmd.output()
|
||||
.map_err(|e| ExecError::Spawn(format!("无法执行 {prog}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_shell_metachar_in_args() {
|
||||
for (prog, args) in [
|
||||
("npm", vec!["a|b".to_string()]),
|
||||
("npm", vec!["a&&b".to_string()]),
|
||||
("sh", vec!["$(id)".to_string()]),
|
||||
("sh", vec!["`whoami`".to_string()]),
|
||||
("sh", vec!["a;b".to_string()]),
|
||||
] {
|
||||
assert!(matches!(validate_argv(prog, &args), Err(ExecError::DangerousInput(_))),
|
||||
"{prog} {args:?} 应被拒绝");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_metachar_in_executable() {
|
||||
assert!(matches!(validate_argv("a|b", &[]), Err(ExecError::DangerousInput(_))));
|
||||
assert!(matches!(validate_argv("", &[]), Err(ExecError::InvalidSlot(_))));
|
||||
assert!(matches!(validate_argv("-rf", &[]), Err(ExecError::InvalidSlot(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_plain_argv() {
|
||||
assert!(validate_argv("npm", &["install".into(), "-g".into(), "@openai/codex".into()]).is_ok());
|
||||
assert!(validate_argv("C:\\Program Files\\nodejs\\node.exe", &["--version".into()]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_within_allowed_root() {
|
||||
let root = std::env::temp_dir();
|
||||
let ok = root.join("agentdock-exec-ok");
|
||||
let out = std::env::temp_dir().join("agentdock-exec-out");
|
||||
let _ = std::fs::create_dir_all(&ok);
|
||||
assert!(ensure_cwd_within(&ok, &[root.clone()]).is_ok());
|
||||
assert!(ensure_cwd_within(&out, &[ok.clone()]).is_err());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn spawn_runs_argv_without_shell() {
|
||||
let out = spawn("where.exe", &["where".to_string()], None).expect("where.exe 应可执行");
|
||||
assert!(out.status.success());
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn spawn_runs_argv_without_shell() {
|
||||
let out = spawn("/bin/true", &[], None).expect("/bin/true 应可执行");
|
||||
assert!(out.status.success());
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
//! agentdock-exec —— 安全进程执行层(架构 §3.2 / §4.2)
|
||||
//!
|
||||
//! 职责:仅执行适配器声明的 argv,禁止 shell 拼接、管道、重定向;
|
||||
//! 参数白名单校验。Wave 0:空骨架,随 Wave 1 落地。
|
||||
//! 职责:仅执行适配器声明的 argv 数组,禁止 shell 拼接、管道、重定向;
|
||||
//! 参数槽位白名单校验;工作目录限制。
|
||||
|
||||
/// 执行层能力标记(占位)
|
||||
pub const LAYER: &str = "agentdock-exec";
|
||||
pub mod error;
|
||||
pub mod exec;
|
||||
pub mod slots;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_identity() {
|
||||
assert_eq!(LAYER, "agentdock-exec");
|
||||
}
|
||||
}
|
||||
pub use error::ExecError;
|
||||
pub use exec::{ensure_cwd_within, spawn, validate_argv};
|
||||
pub use slots::{Slot, validate_slot};
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! 参数槽位白名单校验(架构 §4.2:用户输入只作为已校验参数槽位)
|
||||
|
||||
use crate::error::ExecError;
|
||||
use crate::exec::first_metachar;
|
||||
|
||||
/// 参数槽位类型。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Slot {
|
||||
/// 枚举:值必须在白名单内
|
||||
Enum(Vec<&'static str>),
|
||||
/// 版本号:纯数字 + 点 + 可选连字符(如 1.2.3 / 24.18.0)
|
||||
Version,
|
||||
/// 路径:禁止 shell 元字符
|
||||
Path,
|
||||
/// 普通参数:禁止 shell 元字符、禁止为空
|
||||
Plain,
|
||||
}
|
||||
|
||||
/// 校验单个参数槽位。
|
||||
pub fn validate_slot(slot: &Slot, value: &str) -> Result<(), ExecError> {
|
||||
if value.is_empty() {
|
||||
return Err(ExecError::InvalidSlot("参数不能为空".into()));
|
||||
}
|
||||
if let Some(c) = first_metachar(value) {
|
||||
return Err(ExecError::DangerousInput(format!("参数含 shell 元字符 {c:?}: {value}")));
|
||||
}
|
||||
match slot {
|
||||
Slot::Enum(allowed) => {
|
||||
if !allowed.iter().any(|a| *a == value) {
|
||||
return Err(ExecError::InvalidSlot(format!(
|
||||
"值「{value}」不在白名单 {allowed:?} 内"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Slot::Version => {
|
||||
if !value.chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-') {
|
||||
return Err(ExecError::InvalidSlot(format!("「{value}」不是合法版本号")));
|
||||
}
|
||||
}
|
||||
Slot::Path | Slot::Plain => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enum_whitelist_accepts_listed_value() {
|
||||
let slot = Slot::Enum(vec!["npm", "winget", "apt"]);
|
||||
assert!(validate_slot(&slot, "npm").is_ok());
|
||||
assert!(validate_slot(&slot, "winget").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_whitelist_rejects_unknown_value() {
|
||||
let slot = Slot::Enum(vec!["npm", "winget", "apt"]);
|
||||
match validate_slot(&slot, "choco") {
|
||||
Err(ExecError::InvalidSlot(m)) => assert!(m.contains("choco"), "错误应包含被拒值: {m}"),
|
||||
other => panic!("应返回 InvalidSlot,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_slot_accepts_and_rejects() {
|
||||
let slot = Slot::Version;
|
||||
assert!(validate_slot(&slot, "24.18.0").is_ok());
|
||||
assert!(validate_slot(&slot, "3.14.6").is_ok());
|
||||
assert!(matches!(validate_slot(&slot, "abc"), Err(ExecError::InvalidSlot(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_metachar_in_any_slot() {
|
||||
for slot in [
|
||||
Slot::Plain,
|
||||
Slot::Path,
|
||||
Slot::Version,
|
||||
Slot::Enum(vec!["npm"]),
|
||||
] {
|
||||
assert!(matches!(validate_slot(&slot, "a|b"), Err(ExecError::DangerousInput(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty() {
|
||||
assert!(matches!(validate_slot(&Slot::Plain, ""), Err(ExecError::InvalidSlot(_))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user