feat(wave-1): 适配器框架与安全基座(schema校验/dry-run/exec沙箱/密钥库/日志脱敏/字段拆分)

This commit is contained in:
AgentDock 施工员
2026-08-25 00:38:10 +08:00
parent 296843215f
commit 7208586850
39 changed files with 2323 additions and 81 deletions
+31
View File
@@ -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 {}
+141
View File
@@ -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());
}
}
+8 -13
View File
@@ -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};
+89
View File
@@ -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(_))));
}
}