Windows可读路径授权
Windows 沙箱的可读集合是一个经过多次裁剪的数据结构,而不是一个“允许读取的目录列表”。输入来自 PermissionProfile、command cwd、workspace roots、Codex home、helper 目录、环境变量和可选 override;输出则分成供 elevated setup 使用的 read_roots、write_roots 与 deny_read_paths。每一步都影响后续 ACL 的有效范围:用户 profile 要展开并排除敏感顶层目录,write root 不能同时作为宽泛 read root,deny-read glob 只能把已经存在的匹配项物化为 ACL 目标,缺失的精确路径仍需保留其字面形式。
本文承接WindowsSandbox架构和PermissionProfile解析。前文解释 backend 选择与执行所有权,本篇只追踪“路径集合如何生成、规范化、传输和落到 ACL”;不把当前非 Windows 主机的路径 fixture 外推为 Windows 内核行为。
1. 输入绑定
ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_workspace_roots 只接受 managed restricted profile,并把 symbolic :workspace_roots 绑定到调用方传入的绝对路径。Windows 路径授权因此是一次执行的快照:同一个 profile 在不同 workspace roots 下可能产生不同的 read/write 集合。
源码位置:codex-rs/windows-sandbox-rs/src/resolved_permissions.rs :: ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_workspace_roots
pub fn try_from_permission_profile_for_workspace_roots(
permission_profile: &PermissionProfile,
workspace_roots: &[AbsolutePathBuf],
) -> Result<Self> {
let mut permissions = Self::try_from_permission_profile(permission_profile)?;
permissions.file_system = permissions
.file_system
.materialize_project_roots_with_workspace_roots(workspace_roots);
Ok(permissions)
}setup 层随后按权限和 cwd 收集 roots。若是 explicit read-roots override,它被视为 split policy 的完整 readable set,但仍保留 helper roots,并由独立布尔值决定是否加入平台默认目录。
源码位置:codex-rs/windows-sandbox-rs/src/setup.rs :: build_payload_roots
let mut read_roots = if let Some(roots) = overrides.read_roots.as_deref() {
// An explicit override is the split policy's complete readable set. Keep only the
// helper/platform roots the elevated setup needs; do not re-add legacy cwd/full-read roots.
let mut read_roots = gather_helper_read_roots(request.codex_home);
if overrides.read_roots_include_platform_defaults {
read_roots.extend(
WINDOWS_PLATFORM_DEFAULT_READ_ROOTS
.iter()
.map(PathBuf::from),
);
}
read_roots.extend(roots.iter().cloned());
canonical_existing(&read_roots)
} else {
gather_read_roots(
request.command_cwd,
request.permissions,
request.env_map,
request.codex_home,
)
};2. 用户目录与敏感根
如果收集结果包含 USERPROFILE 本身,setup 不直接把整个用户目录作为一个 read root,而是展开为可读子目录。随后按固定排除表过滤 .ssh、.aws、.kube 等顶层目录,再排除与 SSH 配置依赖和 Codex sandbox 控制目录相关的路径。这样做的目的不是隐藏目录名,而是避免将凭据和 sandbox 自身状态作为宽泛 read grant 传播给子进程。
源码位置:codex-rs/windows-sandbox-rs/src/setup.rs :: expand_user_profile_root_for、filter_user_profile_root_exclusions
fn expand_user_profile_root_for(roots: Vec<PathBuf>, user_profile: &Path) -> Vec<PathBuf> {
let user_profile_key = canonical_path_key(user_profile);
let mut expanded = Vec::new();
for root in roots {
if canonical_path_key(&root) == user_profile_key {
expanded.extend(profile_read_roots(user_profile));
} else {
expanded.push(root);
}
}
expanded.sort_by_key(|root| canonical_path_key(root));
expanded.dedup_by(|a, b| canonical_path_key(a.as_path()) == canonical_path_key(b.as_path()));
expanded
}
fn filter_user_profile_root_exclusions(mut roots: Vec<PathBuf>) -> Vec<PathBuf> {
let Ok(user_profile) = std::env::var("USERPROFILE") else {
return roots;
};
let user_profile = Path::new(&user_profile);
roots.retain(|root| !is_user_profile_root_exclusion(root, user_profile));
roots
}profile_read_roots 在枚举失败时可以回退到 profile root,但这个 fallback 只说明“无法进一步枚举”,不应解释为总是授予用户目录的完整读取权。敏感目录和 sandbox 控制目录还会在后续过滤阶段移除。
源码位置:codex-rs/windows-sandbox-rs/src/setup.rs :: filter_sensitive_write_roots
fn filter_sensitive_write_roots(mut roots: Vec<PathBuf>, codex_home: &Path) -> Vec<PathBuf> {
let codex_home_key = canonical_path_key(codex_home);
let sbx_dir_key = canonical_path_key(&sandbox_dir(codex_home));
let sbx_dir_prefix = format!("{}/", sbx_dir_key.trim_end_matches('/'));
let sbx_bin_dir_key = canonical_path_key(&sandbox_bin_dir(codex_home));
let sbx_bin_dir_prefix = format!("{}/", sbx_bin_dir_key.trim_end_matches('/'));
let secrets_dir_key = canonical_path_key(&sandbox_secrets_dir(codex_home));
let secrets_dir_prefix = format!("{}/", secrets_dir_key.trim_end_matches('/'));
roots.retain(|root| {
let key = canonical_path_key(root);
key != codex_home_key
&& key != sbx_dir_key
&& !key.starts_with(&sbx_dir_prefix)
&& key != sbx_bin_dir_key
&& !key.starts_with(&sbx_bin_dir_prefix)
&& key != secrets_dir_key
&& !key.starts_with(&secrets_dir_prefix)
});
roots
}3. Canonical key
canonicalize_path 在 dunce::canonicalize 失败时保留原路径;canonical_path_key 再统一分隔符并转小写。这个 key 用于排序、去重和前缀比较,但不能把“canonicalize 失败后的字面路径”误称为已经解析到真实 reparse target。
源码位置:codex-rs/windows-sandbox-rs/src/path_normalization.rs :: canonicalize_path、canonical_path_key
pub fn canonicalize_path(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
pub fn canonical_path_key(path: &Path) -> String {
canonicalize_path(path)
.to_string_lossy()
.replace('\\', "/")
.to_ascii_lowercase()
}构造 payload 时,read root 先移除 canonical key 与 write root 相同的项,再移除被 deny-read 前缀覆盖的宽泛 root。注意这里是 exact equality 和 canonical prefix 的组合:一个 deny path 可以裁掉更宽的 read root,但不会删除无关 sibling。
源码位置:codex-rs/windows-sandbox-rs/src/setup.rs :: build_payload_roots
let write_root_set: HashSet<PathBuf> = write_roots.iter().cloned().collect();
let deny_read_keys: Vec<String> = overrides
.deny_read_paths
.as_deref()
.unwrap_or_default()
.iter()
.map(|path| canonical_path_key(path))
.collect();
read_roots.retain(|root| {
if write_root_set.contains(root) {
return false;
}
if deny_read_keys.is_empty() {
return true;
}
let root_key = canonical_path_key(root);
!deny_read_keys
.iter()
.any(|denied| Path::new(&root_key).starts_with(denied))
});4. Deny-read glob
Windows ACL API 不理解 Codex 的 glob 语法,因此 resolve_windows_deny_read_paths 把 exact unreadable roots 直接加入结果,再把 glob entries 转成 ReadDenyMatcher,从第一个 glob 元字符之前的字面目录前缀开始扫描。普通 *.env 的扫描深度由 pattern 组件数决定;** 则必须使用配置的 glob_scan_max_depth,否则从文件系统根开始的无限扫描会直接报错。
源码位置:codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs :: resolve_windows_deny_read_paths
pub fn resolve_windows_deny_read_paths(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &AbsolutePathBuf,
) -> Result<Vec<AbsolutePathBuf>, String> {
let mut paths = Vec::new();
let mut seen = HashSet::new();
for path in file_system_sandbox_policy.get_unreadable_roots_with_cwd(cwd.as_path()) {
push_absolute_path(&mut paths, &mut seen, path.into_path_buf())?;
}
let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd.as_path());
if unreadable_globs.is_empty() {
return Ok(paths);
}
let glob_policy = FileSystemSandboxPolicy::restricted(
unreadable_globs
.iter()
.map(|pattern| FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: pattern.clone(),
},
access: FileSystemAccessMode::Deny,
missing_path_behavior: None,
})
.collect(),
);
let Some(matcher) = ReadDenyMatcher::try_new(&glob_policy, cwd.as_path())? else {
return Ok(paths);
};
let scan_plans = unreadable_globs
.iter()
.map(|pattern| {
let scan_plan = glob_scan_plan(pattern, file_system_sandbox_policy.glob_scan_max_depth);
if scan_plan.max_depth.is_none() && scan_plan.root.parent().is_none() {
return Err(format!(
"unreadable glob `{pattern}` cannot be safely expanded from a filesystem root without `glob_scan_max_depth`; configure `glob_scan_max_depth` or use a non-root directory prefix"
));
}
Ok(scan_plan)
})
.collect::<Result<Vec<_>, String>>()?;实际递归扫描只收集现有匹配,使用 canonical directory key 防止 symlink/junction 环路;扫描深度到达上限或目录无法读取时停止,不把“未来可能出现的 glob 匹配”伪造为当前 ACL 目标。
源码位置:codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs :: collect_existing_glob_matches
fn collect_existing_glob_matches(
path: &Path,
matcher: &ReadDenyMatcher,
paths: &mut Vec<AbsolutePathBuf>,
seen_paths: &mut HashSet<PathBuf>,
seen_scan_dirs: &mut HashSet<PathBuf>,
max_depth: Option<usize>,
depth: usize,
) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
if matcher.is_read_denied(path) {
push_absolute_path(paths, seen_paths, path.to_path_buf())?;
}
let Ok(metadata) = path.metadata() else {
return Ok(());
};
if !metadata.is_dir() {
return Ok(());
}
let scan_key = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if !seen_scan_dirs.insert(scan_key) {
return Ok(());
}
if max_depth.is_some_and(|max_depth| depth >= max_depth) {
return Ok(());
}
let Ok(entries) = std::fs::read_dir(path) else {
return Ok(());
};
for entry in entries.flatten() {
collect_existing_glob_matches(
&entry.path(),
matcher,
paths,
seen_paths,
seen_scan_dirs,
max_depth,
depth + 1,
)?;
}
Ok(())
}5. ACL目标双路径
解析器输出进入 ACL 层后,plan_deny_read_acl_paths 对每条路径先保留 lexical spelling;如果目标已经存在,再追加 canonicalize_path 的结果。missing exact path 因此不会丢失,reparse-point 指向的现有对象也不会只靠字面 alias 保护。
源码位置:codex-rs/windows-sandbox-rs/src/deny_read_acl.rs :: plan_deny_read_acl_paths
pub fn plan_deny_read_acl_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
let mut planned = Vec::new();
let mut seen = HashSet::new();
for path in paths {
push_planned_path(&mut planned, &mut seen, path.to_path_buf());
if path.exists() {
push_planned_path(&mut planned, &mut seen, canonicalize_path(path));
}
}
planned
}应用 ACL 时,缺失路径先创建目录,再加入 deny-read ACE;中途任何一个路径失败,会撤销本次调用已经添加的 ACE,避免留下半套持久状态。
源码位置:codex-rs/windows-sandbox-rs/src/deny_read_acl.rs :: apply_deny_read_acls
for path in planned {
let result = (|| -> Result<bool> {
if !path.exists() {
std::fs::create_dir_all(&path)
.with_context(|| format!("create deny-read path {}", path.display()))?;
}
add_deny_read_ace(&path, psid)
.with_context(|| format!("apply deny-read ACE to {}", path.display()))
})();
let added = match result {
Ok(added) => added,
Err(err) => {
for added_path in &added_in_this_call {
revoke_ace(added_path, psid);
}
return Err(err);
}
};
if added {
added_in_this_call.push(path.clone());
}
push_planned_path(&mut applied, &mut seen, path);
}6. Setup与wrapper
setup payload 保留配置字面路径,便于 ACL 层同时规划 lexical/canonical 目标;read roots 则由 build_payload_roots 先 canonical existing、过滤 profile、排除 write overlap 和 deny-read 覆盖。wrapper 传输时,JSON override 与 command separator 都由显式参数承载,缺少必需字段或相对路径会在创建 session 前失败。
源码位置:codex-rs/windows-sandbox-rs/src/setup.rs :: build_payload_deny_read_paths
fn build_payload_deny_read_paths(explicit_deny_read_paths: Option<Vec<PathBuf>>) -> Vec<PathBuf> {
// Keep the configured spelling here so the ACL layer can plan both the
// lexical path and any existing canonical target for reparse-point aliases.
explicit_deny_read_paths.unwrap_or_default()
}源码位置:codex-rs/windows-sandbox-rs/src/wrapper.rs :: build_wrapper_command
if let Some(read_roots_override) = read_roots_override {
push_json_arg(&mut args, READ_ROOTS_JSON_FLAG, &read_roots_override);
}
if read_roots_include_platform_defaults {
args.push(READ_ROOTS_INCLUDE_PLATFORM_DEFAULTS_FLAG.to_string());
}
if let Some(write_roots_override) = write_roots_override {
push_json_arg(&mut args, WRITE_ROOTS_JSON_FLAG, &write_roots_override);
}
if !deny_read_paths_override.is_empty() {
push_json_arg(
&mut args,
DENY_READ_PATHS_JSON_FLAG,
&deny_read_paths_override,
);
}
if !deny_write_paths_override.is_empty() {
push_json_arg(
&mut args,
DENY_WRITE_PATHS_JSON_FLAG,
&deny_write_paths_override,
);
}
args.push("--".to_string());
args.extend(command);wrapper 解析后,如果 workspace roots 为空,会把 command cwd 作为默认 root;但 codex_home、command cwd、环境 JSON、profile、sandbox level 和 command separator 仍是必填项。这个默认只补 workspace root,不会补缺失的权限或 read override。
7. 测试与边界
路径授权测试可以在非 Windows target 上验证纯函数和 fixture:canonical key 的大小写/分隔符等价、exact missing deny path 保留、glob 从 literal prefix 扫描、递归 glob 没有深度上限时拒绝、非递归 glob 深度受限、symlink/junction 扫描去环。ACL ACE 应用、reparse point 解析和 setup helper 的 Windows API 行为必须在 Windows target 执行。
源码位置:codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs :: root_recursive_globs_without_depth_fail_before_expansion、configured_depth_caps_recursive_glob_scans
#[test]
fn root_recursive_globs_without_depth_fail_before_expansion() {
let tmp = TempDir::new().expect("tempdir");
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("absolute cwd");
let root = cwd.as_path().ancestors().last().expect("filesystem root");
let pattern = root.join("**").join("*.env").display().to_string();
let policy =
FileSystemSandboxPolicy::restricted(vec![unreadable_glob_entry(pattern.clone())]);
assert_eq!(
resolve_windows_deny_read_paths(&policy, &cwd).expect_err("unbounded root glob"),
format!(
"unreadable glob `{pattern}` cannot be safely expanded from a filesystem root without `glob_scan_max_depth`; configure `glob_scan_max_depth` or use a non-root directory prefix"
)
);
}源码位置:codex-rs/windows-sandbox-rs/src/path_normalization.rs :: canonical_path_key_normalizes_case_and_separators
该测试用同一路径的大小写与分隔符变体作为输入,断言两者生成相同 canonical key;测试证明比较键的归一化规则,不证明任意路径都能成功 canonicalize。
这些断言证明的是集合解析和错误选择,不是“ACL 已经成功保护文件”。在 Windows target 上可继续运行:
cd codex-rs
cargo test -p codex-windows-sandbox --lib deny_read_resolver
cargo test -p codex-windows-sandbox --lib path_normalization
cargo test -p codex-windows-sandbox --lib setup当前非 Windows 主机能够执行解析器、规范化和部分序列化 fixture,但不能验证 Windows token、ACL inheritance、reparse point 或 elevated setup helper 的内核效果。
8. 阅读闭环
建议按 ResolvedWindowsSandboxPermissions → build_payload_roots → profile/敏感目录过滤 → canonical key → write-root/deny-read 裁剪 → resolve_windows_deny_read_paths → ACL target planning → wrapper JSON 的顺序阅读。读完后应能回答:为什么 helper roots 不能被 override 丢掉;为什么 ** 在文件系统根上必须有深度上限;为什么 lexical path 和 canonical target 要同时保留;以及为什么 Windows 的 deny-read 约束会把某些 profile 推向 elevated backend。
下一篇进入 Windows sandbox 测试矩阵与平台限制。
