Skip to content

Commit 84f6330

Browse files
committed
Merge branch 'shz_vide/ecstatic-feistel-a3d8eb' into claude/release-v1.0.7-content
2 parents a375fde + 0fb1973 commit 84f6330

8 files changed

Lines changed: 139 additions & 18 deletions

File tree

‎CLAUDE.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ ACL 幂等检查不能只按路径缓存。Windows 不隔离网络,Everyone/
105105
**权限清单与审批扩展(openspec align-codex-sandboxing)**:`SandboxPolicy` 由 read /
106106
write / deny 三类条目派生(`config.sandbox.filesystem.*` + 默认 deny 名单 + 会话授权),
107107
判定「最长前缀优先,同深度 deny > write > read」;Windows 受限令牌管不了读,deny 只拦写。
108+
**比较两侧必须同口径**:策略条目是 canonical 形态(`make_sandbox_policy` 已 weakly_canonical),
109+
待判路径(`resolve_access`、`is_denied`、授权过滤 `denied_by`、「只放行该目录」建议)一律先
110+
`canonical_utf8` 再比 —— 曾经授权与建议拿词法形态比,经 junction 别名(本机 `C:\Users\shao` →
111+
`N:\Users\shao`)申请 `~/.ssh` 下尚不存在的目录就绕过了 deny 名单。单测里手拼 `SandboxPolicy`
112+
也要用 `canonical_policy_path` 填条目:Windows 上 `/work` 按当前盘解析,当前盘恰好有 `\work`
113+
(本机 `C:\work`)时未规范化的条目对不上,用例随机器时过时不过。回归:
114+
`sandbox_policy_test.cpp::DenyListStillWinsWhenGrantUsesDirectoryAlias`、
115+
`sandbox_denial_test.cpp::SuggestedWriteRootRejectsDeniedDirectoryReachedThroughAlias`。
108116
bash 参数是 Codex 同款 `sandbox_permissions=use_default|with_additional_permissions|require_escalated`
109117
+ `additional_permissions` + `prefix_rule`(旧 `with_escalated_permissions` 是别名)。
110118
决策表新增 `unattended`:active goal 下越权 / 额外权限申请一律 Forbidden(`escalation_unattended`),

‎docs/sandbox.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ Seatbelt 策略按 Codex `create_seatbelt_command_args_with_profile` 组装:
144144
临时加上申请的路径 / 网络)、`require_escalated`(申请沙盒外执行);旧参数
145145
`with_escalated_permissions=true` 等价 `require_escalated`。非 `use_default` 必须带非空
146146
`justification`;`with_additional_permissions` 必须带非空 `additional_permissions`,路径必须是
147-
绝对路径或 `~` 开头;deny 名单里的路径不能通过申请变可写。`prefix_rule` 是模型建议的可记住
147+
绝对路径或 `~` 开头;deny 名单里的路径不能通过申请变可写 —— 判定前申请路径与 deny 条目都按真实
148+
路径规范化,经 junction / 符号链接别名(如 `C:\Users\x` 指向 `N:\Users\x`)写的路径同样被拦。`prefix_rule` 是模型建议的可记住
148149
前缀,必须覆盖全部命令段且不在禁用名单里才被采用。
149150

150151
请求确认时,`args.permission` 给出原因、批准后的沙盒边界、可记忆前缀、申请的额外权限、

‎src/domain/sandbox/sandbox_denial.cpp‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -181,13 +181,9 @@ std::string suggested_write_root(const SandboxViolation& violation, const Sandbo
181181
// 已经可写的目录不是「被拒」的原因;deny 名单里的目录不能通过申请变可写。
182182
if (policy.can_write(candidate)) return {};
183183
if (policy.resolve_access(candidate) == FsAccess::Deny && !policy.full_disk_read()) return {};
184-
for (const auto& denied : policy.denied_paths) {
185-
const auto rel = path_from_utf8(candidate).lexically_relative(path_from_utf8(denied));
186-
if (!rel.empty() && *rel.begin() != "..") return {};
187-
}
188-
for (const auto& glob : policy.denied_globs) {
189-
if (glob_matches_path(glob, candidate)) return {};
190-
}
184+
// 候选目录不存在时只是词法形态;is_denied 与 resolve_access 同口径规范化后再比,
185+
// 经别名(junction / 符号链接)写的被拒路径也认得出 deny 名单。
186+
if (policy.is_denied(candidate)) return {};
191187
return candidate;
192188
}
193189

‎src/domain/sandbox/sandbox_policy.cpp‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,10 @@ void expand_denied_entries(const SandboxPolicyOptions& options, const EntryConte
487487

488488
bool denied_by(const std::vector<std::string>& denied_paths, const std::vector<std::string>& denied_globs,
489489
const std::string& path) {
490-
const std::string p = comparable(path);
490+
// deny 条目是 canonical 形态,待判路径按 resolve_access 的同一口径先规范化。申请路径
491+
// 指向尚不存在的目录时 normalize_policy_path 只做词法归一,经 junction / 符号链接的
492+
// 别名(C:\Users\x → N:\Users\x)逐字比较就漏判,~/.ssh 下的新目录会被授权成可写根。
493+
const std::string p = comparable(canonical_utf8(path));
491494
for (const auto& d : denied_paths) {
492495
if (is_same_or_under(comparable(d), p)) return true;
493496
}
@@ -499,6 +502,10 @@ bool denied_by(const std::vector<std::string>& denied_paths, const std::vector<s
499502

500503
} // namespace
501504

505+
bool SandboxPolicy::is_denied(const std::string& absolute_path) const {
506+
return denied_by(denied_paths, denied_globs, absolute_path);
507+
}
508+
502509
std::vector<WritableRoot> compute_writable_roots(const std::string& write_root,
503510
const SandboxPolicyOptions& options) {
504511
std::vector<WritableRoot> roots;

‎src/domain/sandbox/sandbox_policy.hpp‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ struct SandboxPolicy {
5757
FsAccess resolve_access(const std::string& absolute_path) const;
5858
bool can_write(const std::string& absolute_path) const;
5959
bool can_read(const std::string& absolute_path) const;
60+
// 路径本身或其祖先在 deny 名单 / deny glob 上(不看可写根)。
61+
bool is_denied(const std::string& absolute_path) const;
6062
};
6163

6264
// AgentLoop 注入到 ToolContext 的"这次 bash 怎么跑":策略 + 用哪个后端 +

‎tests/sandbox/sandbox_denial_test.cpp‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
#include <gtest/gtest.h>
22
#include "sandbox/sandbox_denial.hpp"
3+
#include "sandbox/sandbox_policy.hpp"
4+
#include "test_support/sandbox/test_support.hpp"
5+
#include "utils/utf8_path.hpp"
36
#include <csignal>
47

58
using namespace acecode::sandbox;
@@ -62,12 +65,15 @@ TEST(SandboxDenial, ExtractsDeniedPathAcrossPlatforms) {
6265
// 场景:给「只放行该目录」算建议目录。期望:被拒路径是文件 → 父目录;已经可写的
6366
// 目录不建议(不是被拒原因);deny 名单里的目录 / 命中 deny glob 的目录不建议;
6467
// 相对路径不建议。
68+
// 条目按 make_sandbox_policy 的约定先规范化(canonical_policy_path):Windows 上 `/work`
69+
// 按当前盘解析,当前盘有 \work 目录时(本机 C:\work)候选目录会变成 C:\work。
70+
// 回归:条目曾直接写 `/work`,在存在 C:\work 的机器上「已可写」判不出来,建议成了 "C:\work"。
6571
TEST(SandboxDenial, SuggestedWriteRootRespectsPolicyAndDenyList) {
6672
SandboxPolicy policy;
6773
policy.mode = SandboxMode::WorkspaceWrite;
68-
policy.writable_roots = {{"/work", {}}};
69-
policy.denied_paths = {"/home/u/.ssh"};
70-
policy.denied_globs = {"/home/u/**/.secrets"};
74+
policy.writable_roots = {{canonical_policy_path("/work"), {}}};
75+
policy.denied_paths = {canonical_policy_path("/home/u/.ssh")};
76+
policy.denied_globs = {canonical_policy_path("/home/u") + "/**/.secrets"};
7177
SandboxViolation v;
7278
v.path = "/home/u/.cache/pnpm/store.lock";
7379
EXPECT_EQ(suggested_write_root(v, policy), "/home/u/.cache/pnpm");
@@ -83,6 +89,37 @@ TEST(SandboxDenial, SuggestedWriteRootRespectsPolicyAndDenyList) {
8389
EXPECT_EQ(suggested_write_root(v, policy), "");
8490
}
8591

92+
// 场景:命令报 <别名>/.ssh/newdir/key 被拒,其中别名是指向真实家目录的 junction /
93+
// 符号链接(本机 C:\Users\shao → N:\Users\shao),newdir 还不存在;deny 名单里的 ~/.ssh
94+
// 是 canonical 的真实路径。另有一个经别名写、但不在 deny 名单里的普通目录。
95+
// 期望:.ssh 下的目录不给「只放行该目录」建议;普通目录照常建议(修复不能误伤)。
96+
// 回归:建议逻辑拿词法形态的候选目录和 canonical 的 deny 条目做 lexically_relative,别名
97+
// 形态对不上,越权确认框里出现「只放行 <别名>/.ssh/newdir」的选项。
98+
TEST(SandboxDenial, SuggestedWriteRootRejectsDeniedDirectoryReachedThroughAlias) {
99+
acecode::sandbox::test::TempTree tree;
100+
const auto real_home = tree.dir("real-home");
101+
tree.dir("real-home/.ssh");
102+
const auto workspace = tree.dir("work");
103+
const auto alias_home = tree.root / "alias-home";
104+
if (!acecode::sandbox::test::make_directory_alias(real_home, alias_home)) {
105+
GTEST_SKIP() << "cannot create a directory alias here";
106+
}
107+
SandboxPolicyOptions options;
108+
options.include_tmpdir = false;
109+
options.home_override = acecode::path_to_utf8(alias_home);
110+
options.denied_entries = {"~/.ssh"};
111+
const auto policy = make_sandbox_policy(
112+
SandboxMode::WorkspaceWrite, acecode::path_to_utf8(workspace), options);
113+
114+
SandboxViolation v;
115+
v.path = acecode::path_to_utf8(alias_home / ".ssh" / "newdir" / "key");
116+
EXPECT_EQ(suggested_write_root(v, policy), "");
117+
v.path = acecode::path_to_utf8(alias_home / ".ssh" / "key");
118+
EXPECT_EQ(suggested_write_root(v, policy), "");
119+
v.path = acecode::path_to_utf8(alias_home / "projects" / "out.txt");
120+
EXPECT_NE(suggested_write_root(v, policy), "") << "deny 名单之外的普通目录照常建议";
121+
}
122+
86123
// 场景:升级提示文案。期望:后端不拦网络且不做准断网(旧 Windows 语义)时不提网络;
87124
// 拦网络时说"blocked";准断网时说"discouraged";带被拒路径时先建议
88125
// with_additional_permissions 只加该目录,再提 require_escalated;deny 名单存在时

‎tests/sandbox/sandbox_policy_test.cpp‎

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,29 @@ TEST(SandboxPolicy, GlobMatchingFollowsGitStyleSemantics) {
8383
EXPECT_FALSE(glob_matches_path("/home/u/[!a]*.key", "/home/u/a1.key"));
8484
}
8585

86+
namespace {
87+
88+
// 手工拼的策略也要守 make_sandbox_policy 的约定:条目是 canonical 形态
89+
// (WritableRoot::root 等「已 weakly_canonical」),查询路径由 resolve_access 自己规范化。
90+
// Windows 上 `/work` 这类无盘符路径按当前盘解析:当前盘恰好有 \work 目录时(本机
91+
// 2026-09-22 起就有 C:\work)查询变成 C:\work\...,未规范化的条目还是 /work,两侧对不上 ——
92+
// 旧写法让用例随机器上有没有这个目录时过时不过。
93+
std::string canon(const std::string& path) { return canonical_policy_path(path); }
94+
95+
} // namespace
96+
8697
// 场景:deny 名单、可写根、只读子路径、可读根共存时的访问判定。期望:最长前缀
8798
// 命中优先(写根下的 deny 子目录仍 Deny;deny 目录下配置的写根反而可写),同深度
8899
// deny > write > read;全盘可读时未命中的路径 Read,受限读时 Deny;FullAccess 恒 Write。
100+
// 回归:条目曾直接写 `/work` 而不经规范化,在存在 C:\work 的 Windows 机器上
101+
// `/work/src/main.cpp` 判成 Read、`/work/secrets/key` 判成 Read(期望 Write / Deny)。
89102
TEST(SandboxPolicy, ResolveAccessPrefersDeepestEntryThenDenyWriteRead) {
90103
SandboxPolicy policy;
91104
policy.mode = SandboxMode::WorkspaceWrite;
92-
policy.writable_roots = {{"/work", {"/work/.acecode/rules"}}, {"/home/u/.cache/allowed", {}}};
93-
policy.denied_paths = {"/work/secrets", "/home/u/.cache"};
94-
policy.denied_globs = {"/work/**/.env"};
105+
policy.writable_roots = {{canon("/work"), {canon("/work/.acecode/rules")}},
106+
{canon("/home/u/.cache/allowed"), {}}};
107+
policy.denied_paths = {canon("/work/secrets"), canon("/home/u/.cache")};
108+
policy.denied_globs = {canon("/work") + "/**/.env"};
95109
EXPECT_EQ(policy.resolve_access("/work/src/main.cpp"), FsAccess::Write);
96110
EXPECT_EQ(policy.resolve_access("/work/.acecode/rules/x.rules"), FsAccess::Read);
97111
EXPECT_EQ(policy.resolve_access("/work/secrets/key"), FsAccess::Deny);
@@ -101,19 +115,58 @@ TEST(SandboxPolicy, ResolveAccessPrefersDeepestEntryThenDenyWriteRead) {
101115
EXPECT_EQ(policy.resolve_access("/etc/hosts"), FsAccess::Read) << "全盘可读";
102116
EXPECT_TRUE(policy.can_read("/etc/hosts"));
103117
EXPECT_FALSE(policy.can_write("/etc/hosts"));
104-
policy.readable_roots = {"/work", "/usr"};
118+
policy.readable_roots = {canon("/work"), canon("/usr")};
105119
EXPECT_EQ(policy.resolve_access("/etc/hosts"), FsAccess::Deny) << "受限读未命中";
106120
EXPECT_EQ(policy.resolve_access("/usr/lib/libc.so"), FsAccess::Read);
107121
SandboxPolicy full;
108122
EXPECT_EQ(full.resolve_access("/anything"), FsAccess::Write);
109123
// 同深度:同一路径既是写根又在 deny 里 → Deny。
110124
SandboxPolicy tie;
111125
tie.mode = SandboxMode::WorkspaceWrite;
112-
tie.writable_roots = {{"/both", {}}};
113-
tie.denied_paths = {"/both"};
126+
tie.writable_roots = {{canon("/both"), {}}};
127+
tie.denied_paths = {canon("/both")};
114128
EXPECT_EQ(tie.resolve_access("/both/x"), FsAccess::Deny);
115129
}
116130

131+
// 场景:家目录经 junction / 符号链接以「别名」出现(本机实况:C:\Users\shao 是指向
132+
// N:\Users\shao 的 junction)。deny 名单里的 ~/.ssh 已按 canonical 展开成真实路径;
133+
// 申请(模型的 additional_permissions、会话授权)写的却是 <别名>/.ssh/newdir —— 目录还
134+
// 不存在,normalize_policy_path 只做词法归一,别名原样留着。
135+
// 期望:deny 名单仍压过授权 —— 授权被忽略,真实路径与别名路径下都不可写,is_denied
136+
// 对两种形态都成立。
137+
// 回归:deny 判定(denied_by)拿未规范化的授权路径和 canonical 的 deny 条目逐字比较,
138+
// 别名形态对不上,授权进了可写根;又因为「更深的写根压过浅的 deny」,~/.ssh 下的新
139+
// 目录变成可写。
140+
TEST(SandboxPolicy, DenyListStillWinsWhenGrantUsesDirectoryAlias) {
141+
test::TempTree tree;
142+
const auto real_home = tree.dir("real-home");
143+
tree.dir("real-home/.ssh");
144+
const auto workspace = tree.dir("work");
145+
const auto alias_home = tree.root / "alias-home";
146+
if (!test::make_directory_alias(real_home, alias_home)) {
147+
GTEST_SKIP() << "cannot create a directory alias here";
148+
}
149+
const std::string alias_grant = normalize_policy_path(path_to_utf8(alias_home / ".ssh" / "newdir"));
150+
ASSERT_NE(alias_grant.find("alias-home"), std::string::npos) << "申请路径保持别名形态";
151+
const std::string real_newdir = path_to_utf8(fs::weakly_canonical(real_home / ".ssh" / "newdir"));
152+
153+
SandboxPolicyOptions options;
154+
options.include_tmpdir = false;
155+
options.home_override = path_to_utf8(alias_home);
156+
options.denied_entries = {"~/.ssh"};
157+
options.grants.write = {alias_grant};
158+
const auto policy = make_sandbox_policy(SandboxMode::WorkspaceWrite, path_to_utf8(workspace), options);
159+
160+
for (const auto& root : policy.writable_roots) {
161+
EXPECT_EQ(root.root.find(".ssh"), std::string::npos) << "deny 名单下的授权进了可写根: " << root.root;
162+
}
163+
EXPECT_EQ(policy.resolve_access(real_newdir + "/id_rsa"), FsAccess::Deny);
164+
EXPECT_EQ(policy.resolve_access(alias_grant + "/id_rsa"), FsAccess::Deny);
165+
EXPECT_TRUE(policy.is_denied(alias_grant));
166+
EXPECT_TRUE(policy.is_denied(real_newdir));
167+
EXPECT_FALSE(policy.is_denied(path_to_utf8(workspace / "src")));
168+
}
169+
117170
// 场景:make_sandbox_policy 把配置条目、默认 deny 名单、会话授权揉进策略。期望:
118171
// deny 展开成绝对路径 / glob;落在可写根下的 deny 路径同时列为只读子路径(三平台
119172
// 写拒绝一致);授权的写路径进可写根,但在 deny 名单上的授权被忽略;配置了 read

‎tests/test_support/sandbox/test_support.hpp‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include "utils/utf8_path.hpp"
44
#include <atomic>
55
#include <chrono>
6+
#include <cstdlib>
67
#include <filesystem>
78
#include <fstream>
89
#include <string>
@@ -31,4 +32,20 @@ struct TempTree {
3132
std::ofstream(path, std::ios::binary) << text;
3233
}
3334
};
35+
36+
// 给 target 目录建一个别名目录:Windows 用 junction(mklink /J,普通用户即可,本机
37+
// C:\Users\shao → N:\Users\shao 就是这种形态),POSIX 用符号链接。失败返回 false,
38+
// 调用方 GTEST_SKIP。
39+
inline bool make_directory_alias(const std::filesystem::path& target,
40+
const std::filesystem::path& alias) {
41+
#ifdef _WIN32
42+
const std::string cmd = "cmd /d /c mklink /J \"" + acecode::path_to_utf8(alias) + "\" \"" +
43+
acecode::path_to_utf8(target) + "\" >NUL 2>&1";
44+
return std::system(cmd.c_str()) == 0 && std::filesystem::exists(alias);
45+
#else
46+
std::error_code ec;
47+
std::filesystem::create_directory_symlink(target, alias, ec);
48+
return !ec;
49+
#endif
50+
}
3451
}

0 commit comments

Comments
 (0)