注意:本文完全由AI生成且未经过完整的人工审查,请谨慎参考其中内容
我在 Windows 上用 DeepSeek Harness(下称 DSH)写代码时,碰到一个很奇怪的事:所有命令全部失败,连 pwd 这种纯查询都失败。不是某条命令报错,而是这个工作区压根没法用。
下面把这件事讲清楚:报错是什么意思、DSH 的沙箱在 Windows 上到底做了什么、为什么偏偏这个目录出问题、以及怎么修。
先说结论
报错长这样:
SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\Projects\<项目>)原因:DSH 给工作区目录开沙箱权限时,少了一项叫
WRITE_OWNER的权限。这个目录只继承了"可修改"权限,而沙箱要的比"可修改"多一项。为什么连
pwd都失败:失败发生在启动进程之前。DSH 的规矩是"拿不到隔离就绝不启动",所以不是命令被拒,是命令根本没跑。怎么修:在上一级目录上给自己一次性加上可继承的
WRITE_OWNER(或者继续用 DSH 自带的诊断脚本,每个新工作区修一次)。
一、出事时的三个现象
这三个现象决定了后面怎么查:
- 不是命令的问题,是目录的问题。 把工作区换到用户目录(
%USERPROFILE%下面)里,一切正常;在数据盘上换个新目录,立刻复现。 - 失败在进程创建之前。 报错来自"给沙箱发权限"这一步,不是命令本身。
Win32 5就是"拒绝访问"。 函数名SetNamedSecurityInfoW说明它在写某个对象的权限信息。
要串起这三条,得先懂 Windows 怎么管权限。
二、先搞懂 Windows 怎么判"能不能写"
2.1 一个比喻:房间和保安
把每个文件、文件夹想成一间房间,Windows 就是门口的保安。你想进房间干活,得先过保安。后面的术语都挂在这套比喻上。
2.2 SID:身份证号
Windows 内部不认名字,只认号码。那串像乱码的东西就是号码:
| 号码 | 是谁 |
|---|---|
S-1-5-21-…-1001 |
某个用户账号 |
S-1-5-18 |
SYSTEM(系统自己) |
S-1-5-32-544 |
管理员组 |
S-1-5-11 |
"所有已登录用户" |
S-1-1-0 |
Everyone(所有人) |
S-1-4-… |
谁都不是——可以是沙箱按路径算出来的一张"通行证编号" |
为什么用号码?因为名字能改、会重、还可能被系统按语言翻译。
2.3 令牌:你的工作证
你登录后系统发你一张工作证:你是谁、属于哪些组、有哪些特权、可信等级多高。你启动的每个程序都带着这张证的复印件,保安只看证,不看你是谁。
DSH 的沙箱做的事就是:复印你的工作证,在上面加一句"只准进另写的几个房间",再把等级降一级。所以同一个程序,在沙箱里跑和在外面跑,拿的不是同一张证。这种证有个学名叫受限令牌,那句"只准进哪些房间"叫 restricting SIDs。
2.4 DACL 和 ACE:访客名单,和名单上的每一行
每个房间门口贴一张访客名单:
- 整张名单叫 DACL。
- 名单上每一行叫一个 ACE,一行写四件事:谁的号码、允许还是拒绝、能干什么、要不要让子房间也照办。
号码: S-1-5-32-544 允许 全部 子房间照办
规则是白名单:名单上写了"允许"才能干,没写就是不准;"拒绝"行最优先。名单里存的全是号码,名字只是给你看的。
2.5 继承:名单上的"子房间照此办理"
某一行可以带一句批注:"子房间照此办理"。带了这句,所有子文件夹会自动抄一份到自己的名单里,抄的那份会标上"这是继承来的"。
icacls 输出里的记号:
| 记号 | 意思 |
|---|---|
(OI) |
房间里的文件也照办 |
(CI) |
房间里的子文件夹也照办 |
(IO) |
只给子对象,对本房间自己不生效 |
(I) |
这行是继承来的 |
(NP) |
再往下就不传了 |
反过来,目录也能被设成"到此为止,别往下传"。
2.6 权限位:那一栏"能干什么"
这是最关键的一节。保安不是只问"让不让进",而是一项一项查:
icacls 写法 |
意思 | 掩码 |
|---|---|---|
RX / R |
能看 | — |
M(Modify) |
能看、能改、能删内容 | 0x1301BF |
WDAC |
能改门口这张名单 | 0x40000 |
WO |
能改"房主登记" | 0x80000 |
D |
能删这间房间 | 0x10000 |
DC |
能删房间里里面的东西 | 0x40 |
F |
以上全部 | 0x1F01FF |
这里就是坑:M(能改内容)不包含 WO。
2.7 房主:有一条免检特权,但只到"改名单"为止
每个房间登记一个房主。房主有一条白送的特权:改门口的访客名单,不需要名单上写他的名字(相当于白送 WDAC)。
但这条特权到"改名单"就没了——不包括"改房主登记"(WO)。
2.8 完整性标签、MIC、SACL:和名单无关的第二道锁
只有名单还不够,Windows 还有第二道锁:
- 每个文件、文件夹有个机密等级:Low(低)/ Medium(中)/ High(高)。你的程序是 Medium。
- 规则一句话:等级低的,不能往等级高的地方写。 读随便。
- 这套机制叫 MIC(强制完整性控制),那个标记叫完整性标签,就是几个特殊号码:
S-1-16-4096=Low,S-1-16-8192=Medium,S-1-16-12288=High。
为什么名单不够用? 因为名单是白名单,而"所有已登录用户"这种大组经常被允许一大堆东西。沙箱里那张"复印件工作证"还带着这些大组身份,光看名单拦不住。等级这道锁绕不过去:把子进程降成 Low 之后,凡是没被标成 Low 的地方,它一律写不了,名单怎么写都没用。
而这个标签不在访客名单里,在另一张表上——那张表叫 SACL(平时装的是"谁碰过这个文件"的审计记录)。
2.9 为什么"能读不能写"
受限令牌多了"只准进另写的房间"这一句之后,Windows 对写操作查两遍:
- 先用你原来的身份查名单;
- 再用那张通行证编号查一遍。
两遍都过才给写。 读只查一遍。所以这类沙箱是"读随便、写受限"。
2.10 术语速查
| 术语 | 一句话 |
|---|---|
| SID | 身份证号,Windows 只认号码 |
| 令牌 / 受限令牌 | 工作证 / 上面多印了"只准进指定房间"并降级的复印件 |
| DACL / ACE | 访客名单 / 名单上的一行 |
| 继承 | 名单上"子房间照此办理"那句话 |
| owner | 房主;白送"改名单",但不含"改房主登记" |
| WRITE_DAC(WDAC) | 能改访客名单 |
| WRITE_OWNER(WO) | 能改"房主登记",也是贴完整性标签要用的权限 |
| SACL | 名单之外的另一张表,装审计记录和完整性标签 |
| MIC / 完整性标签 | 机密等级,以及"低的不能往高的写"这条规则 |
| Win32 5 | 就是"拒绝访问" |
三、DSH 的沙箱在 Windows 上具体做了什么
3.1 三档模式
| 模式 | 能写哪里 |
|---|---|
read-only(默认) |
哪里都不能写 |
workspace-write |
只能写工作区根目录 + 一个会话私有的临时目录;进程启动前,TMP/TEMP 会被改到那个临时目录 |
danger-full-access |
不限制 |
3.2 Windows 后端:受限令牌 + 给目录发权限
它不用命名空间(那是 Linux 的做法),而是"复印工作证 + 把工作区目录授权给证上的能力身份":
- 复印当前进程的工作证,标记成
WRITE_RESTRICTED,在 restricting SIDs 里放两个能力身份:- 工作区 SID:由工作区路径算出来,所以同一个工作区在哪个会话里都算出同一个;
- 临时目录 SID:每个"会话 × 工作区"随机生成,互不通用。
- 工作证降级到 Low。
- 于是为了让子进程能写工作区,必须在工作区目录上一次写入三样东西:
| 写什么 | 为什么 |
|---|---|
| 给能力 SID 的允许行 | 让第二遍检查(restricting SID)放行写 |
给所有人(world)的 FILE_DELETE_CHILD 拒绝 |
Windows 允许凭父目录的权限删子项,而这个"凭父目录"的授权不需要通行证副署。不拒掉,子进程就能删掉另一个工作区里的文件。拒掉之后,能删东西的唯一依据就是那张通行证 |
| Low 完整性标签 | 满足"Low 写 Low" |
3.3 关键点:为什么这次合并写入需要 WO
第三样东西——完整性标签——在 SACL 里。而这三样要在同一次权限写入里一起生效,DSH 的文档写明了:这一步需要对该目录的 WRITE_OWNER。
用户目录下的目录天然带"完全控制",所以从来没人注意过;而**"名单只给 Modify"的目录会直接报错,不会静默跳过**。
3.4 它是真沙箱,但有边界
- 这是内核级拦截,不是路径匹配。 同一个命令、同一个字符串,能不能写取决于目标对象的权限信息,不取决于路径怎么写。所以用目录联接、符号链接绕不过去——检查发生在目标对象上。
- 只限写,不限读。 读文件、联网、看进程都不受限。
- 硬链接是同一个文件的别名,授权会落到同一个文件上,所以同一份内容可能从外部别名写进去。
- Low 标签是常驻的。 会话结束后它还在,所以其他跑在 Low 等级的程序也能写这个工作区——这是写边界的代价。
NUL设备两种模式下都能写;CIM/WMI那类命令在受限模式下用不了。
四、为什么偏偏这个目录出问题
4.1 数据盘的默认权限
一台普通的 Windows 机器上,新格式化的数据盘根目录默认大概是这样:
D:\
房主 = SYSTEM
管理员组 : 完全控制(只对本对象)
SYSTEM : 完全控制(只对本对象)
所有已登录用户 : Modify + "子房间照办"
普通用户组 : 读+执行
注意:只有 Modify,没有完全控制,也没有任何一行是写给你本人的。数据盘里的项目目录会把这一整套继承下来:
D:\Projects\<项目>
房主 = 你本人
名单上写给你本人的行:0 条(全是继承来的)
4.2 把探测结果和机制对上
用"直接问内核"的方式探测这个目录(方法见第五节),结果是:
房主 = 你本人
名单上写给你本人的行 = 0 条
WRITE_DAC = True ← 你是房主,白送
WRITE_OWNER = False ← 就是它缺了
于是整件事对上了:
沙箱要往工作区目录写"允许行 + 拒绝项 + Low 标签"
↓ 这次合并写入需要 WRITE_OWNER
这个目录只有继承来的 Modify,没有针对本人的显式行
↓
SetNamedSecurityInfoW 返回 Win32 5(拒绝访问)
↓
DSH 直接放弃:进程根本没被创建
↓
所有命令,包括 pwd,全部失败
4.3 三个不是答案的直觉
| 直觉 | 为什么不对 |
|---|---|
| "C 盘行、D 盘不行" | 盘符不是判据。 系统盘根目录、C:\Users 本身同样缺 WO。真正的原因是用户目录上有一条显式的、可继承的完全控制,所以它下面的新目录天然满足 |
| "目录是我的,凭什么不能改" | 房主身份不是判据。 房主只白送"改名单",不送"改房主登记"。反例更清楚:C:\Users 的房主是管理员组(不是你),可它因为有一条显式允许行而满足条件 |
| "我明明能在这个目录里随便写文件" | "我能写文件"和"沙箱能接管这个目录"是两件事。 前者只要 Modify,后者还要能改权限信息(含 WO) |
唯一管用的判据:这条继承链上,有没有一条对当前账号生效的、带 WO 的行。
五、怎么自己查一个目录行不行
5.1 别靠读名单去猜
人肉读 ACE 再推断"我能不能改权限"很容易翻车,因为要考虑继承、阻断、拒绝行优先、组成员身份、特权、完整性等级、房主隐含位。
正确做法:直接问内核。 请求恰好那一位权限,让系统回答。DSH 的诊断脚本内部就是这么干的,本质等于:
// 请求 WRITE_DAC(0x40000) 或 WRITE_OWNER(0x80000),打开已存在的对象
CreateFileW(path, access, share = 7, NULL, OPEN_EXISTING /*3*/,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT /*0x02200000*/,
NULL);
// 返回 5(拒绝访问)→ 这一位没有
// 返回别的错误 → 是探测本身失败,不能当成"没有"
FILE_FLAG_BACKUP_SEMANTICS 让它能打开目录,FILE_FLAG_OPEN_REPARSE_POINT 让它不跟随快捷方式。
5.2 一段能直接用的自检
function Test-WorkspaceProvisionable {
param([Parameter(Mandatory)][string]$Path)
$acl = Get-Acl -LiteralPath $Path
$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
# 只找"在当前令牌下真正生效"的主体:本人、所有已登录用户、Everyone、Users
$applicable = @($me, 'NT AUTHORITY\Authenticated Users', 'Everyone', 'BUILTIN\Users')
$WRITE_OWNER = 0x80000
$ok = $false
foreach ($ace in $acl.Access) {
if ($ace.AccessControlType -ne 'Allow') { continue }
if ($applicable -notcontains $ace.IdentityReference.Value) { continue }
if (([int]$ace.FileSystemRights -band $WRITE_OWNER) -ne 0) { $ok = $true }
}
"{0} {1}" -f $(if ($ok) { 'OK ' } else { 'NEEDS-FIX ' }), $Path
}
Test-WorkspaceProvisionable 'D:\Projects\<项目>'
5.3 别把管理员组算进来
这是最容易写错的地方:不能写成"名单里有完全控制就行"。
如果当前程序是普通启动的(没提权),那么 Administrators 和 SYSTEM 的完全控制并不生效——它们不是你。把这两个算进去,会对一个真有问题的目录报出假的 OK。
顺带一句:"我是管理员所以没问题"这个想法也不可靠。 身份在名单里,不等于它在当前令牌里生效。
六、DSH 自带的诊断脚本做了什么
DSH 的沙箱包里带了一个诊断脚本,体检和动手术一次做完,没有模式开关。它做得挺讲究,值得说一下。
6.1 它必须在"没有沙箱"的环境里跑
它要读写权限信息,而受限子进程恰好没有这个能力。所以:
- 必须在解除沙箱的会话里跑,或者由你在普通终端里跑;
- 但解除沙箱不等于提权。 它能成功,靠的是普通账号本来就有的"改名单"权限(你是房主),不是管理员身份。
这点很重要:修这个问题一般不需要管理员权限。
6.2 六个阶段
① 准备。 建输出目录,开一份 JSONL 记录(原因见 6.5),检查参数边界。
② 现场编译几个底层调用。 因为 .NET 的现成接口给不了它要的三件事:
上面那种有效访问探测;
只写 DACL 的权限写入——注意给"房主、组、SACL"三个参数全部传 NULL:
SetNamedSecurityInfoW(path, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | PROTECTED_OR_UNPROTECTED, NULL, NULL, dacl, NULL);也就是说:它从不碰房主、从不碰 SACL、从不删任何一行;
读当前令牌的完整性等级,并且专门注明:不要为了查等级去启动
whoami——在低于 Medium 的令牌里那个程序会初始化失败,还会在你桌面上弹一个错误框。
③ 逐级观察。 对请求的目录和它的每一级上级各读一份"体检报告":名单里的所有行(带继承标记)、房主是谁、用探测拿到的 WRITE_DAC/WRITE_OWNER、完整性标签、以及有没有"别的容器工具留下的通行证行"(S-1-15-2-*,其中两个"全体包"组要排除)。
两个细节值得记:
- 读名单时用号码,免得被系统语言翻译名字坑到;
- 读 Low 标签时先按号码匹配(
S-1-16-4096),英文名只当兜底,因为icacls显示标签名会被本地化。认不出的本地化名字要当"不知道",不能当"没有标签"。
④ 判定。 前提条件只看你指定的那个目录(上级的权限不算,否则每个健康目录都会被误判)。然后分类:
| 判定 | 什么时候 |
|---|---|
INCOMPLETE |
有观察失败(缺证据 ≠ 证据表明安全) |
CULPRIT / BOTH |
发现了容器工具的残留行;同时还缺前提就是 BOTH |
UNREADABLE |
名单读不出来 |
PRECONDITION |
缺 WRITE_DAC 或 WRITE_OWNER ← 就是这次 |
NOT_THIS_CLASS |
都不是(并声明"没排除其它原因") |
另外:如果目标缺权限、或者目标就是被授权的根,它会在同一次运行里扫一遍子树(最多 2000 个目录,跳过快捷方式类和受管理目录,读不了的只计数不猜),把容器工具残留行的来源全找出来。目的是"一次审批就把深层问题查完,别让你批第二次"。
⑤ 动手前的闸门。 这是它最讲究的部分:
- 要改的目录必须在允许范围内;
- 不能是盘根、用户目录根、系统目录、
Packages、WindowsApps这类危险位置; - 路径上任何一级都不能是快捷方式/联接(那会把授权搬到别处);
- 任何要改的目录只要缺"改名单"权限,就在动手之前整体放弃——否则会改一半,回滚也做不成;
- 只要有一个目标越界或观察不完整,整条路径直接放弃,绝不改一半。
⑥ 改 → 复验 → 能撤销。
- 先备份:只抄"这个对象自己拥有的行"(继承来的行跟着上级走,抄回去反而会把它钉死),同时保留"是否阻断继承"的标志;写成 JSON,并且把脚本自身复制一份到备份目录——因为技能的临时目录会随注册失效被删掉,回滚脚本必须能独立存在;
- 改:给当前账号追加一条完全控制允许行,然后只写 DACL;
- 复验:改完重新探测一次
WRITE_DAC/WRITE_OWNER,两位都为真才算成功(不是"接口没报错就算成功"); - 回滚:任何异常或拒绝导致非零退出时,按相反顺序把改过的目录名单恢复回去,恢复本身也验证。
6.3 为什么"给自己加一条"就合法,而且不用提权
你是房主
→ 白送"改名单"权限
→ 你有权改这张名单
→ 在名单上给自己写一条"完全控制"
→ 完全控制里含 WO
→ 缺的那一项补上了
用本来就有的"改名单"权限,补上缺的"改房主登记"。这就是整个修复的本质。也正因如此,脚本把"缺改名单权限"当成硬性放弃条件——没有它就没法自举。
6.4 一个容易忽略的细节:它补的那行不传播
脚本用的是三参数的构造方式:
$acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
$sid, [System.Security.AccessControl.FileSystemRights]::FullControl, 'Allow'))
三参数版本不带"子房间照办"标记,所以这行只作用于那一个目录本身,不会让同级目录或以后新建的目录免疫。
你在 icacls 里能直接看出来:写进去的那行是 账号:(F),没有 (OI)(CI);在它下面新建的子目录不会继承到它。
这就解释了那个常见困惑:"我修好过一个目录,为什么换个新目录又不行?"——因为它治的是那一棵,不是一整片。
6.5 为什么输出是 JSONL 加一行 RECAP
命令行工具的屏幕输出只保留末尾。所以脚本把每条记录(观察、判定、动作、验证、恢复)同时写进 acl-report-*.jsonl,最后再补一行 RECAP 和 SUMMARY FIXED=… GRANTED=… REFUSED=… RESTORED=…。
注意 FIXED 和 GRANTED 是两回事:前者数"删掉了几个容器工具残留行",后者数"给几个目录补了前提权限"。这次是 GRANTED=1 FIXED=0——它唯一的改动就是补前提。
七、怎么修
7.1 方案 A:在上一级目录做一次可继承授权(推荐)
思路很简单:既然每次新建工作区都缺同一项,那就在上一级目录上一次性把权限补齐,并加上"子房间照办"。
但有个前提,我实测踩过:这条命令不能在 DSH 的受限会话里跑。 受限会话里连新建目录都改不了权限:
icacls <临时目录> /grant '…'
→ <路径>: Access is denied.
原因和前面一样:沙箱发给你的通行证上写的是"写内容 + 删除",没有"改名单"这一项。
所以两种跑法:自己开一个普通 PowerShell 窗口跑,或者让 DSH 用一次"解除沙箱"的审批来跑(仍然不需要提权)。
# 自动拼出当前账号,免得手写错
$me = "$env:USERDOMAIN\$env:USERNAME"
# ① 先看现状(不改任何东西)
icacls 'D:\Projects'
# ② 最小剂量:改房主登记 + 改名单,并让子目录照办
icacls 'D:\Projects' /grant "${me}:(OI)(CI)(WO,WDAC)"
# ③ 复核
icacls 'D:\Projects'
撤销(随时能退):
icacls 'D:\Projects' /remove:g $me
7.2 语法坑(实测过)
| 写法 | 结果 |
|---|---|
账号:(OI)(CI)(WO,WDAC) |
✅ 可以 |
账号:(WO,WDAC) |
✅ 可以(不带继承) |
账号:(OI)(CI)F |
✅ 可以(完全控制,含 WO) |
账号:(OI)(CI)WO |
❌ Invalid parameter |
记住:单独写某个特殊权限位必须加括号——(OI)(CI)(WO),不能写成 (OI)(CI)WO。想省事就用 (OI)(CI)F。
另外,不管你写名字还是号码,最后存进名单的都是号码。
7.3 副作用
| 副作用 | 有多严重 |
|---|---|
| 会立刻把现有子目录和文件逐个改写一遍权限 | 这就是"子房间照办"的代价。小目录树几秒钟;几十万个文件的目录树会明显变慢。文件内容和修改时间不受影响 |
| 对设了"到此为止、别往下传"的目录无效 | 那里还得单独处理 |
房主不是你本人的对象可能要再加 WDAC |
白送的"改名单"只对房主生效。所以建议 WO,WDAC 一起给,别只给 WO |
| 不会把整个盘变成"低等级保护区" | Low 标签是 DSH 贴在真正当工作区用的那一棵上的,只往下传,不会往上传 |
| 不会让沙箱里的程序多写一个字节 | 子进程能不能写,取决于它那张通行证和 Low 标签。你加的这行只是让 DSH 以后能"发证",边界没变 |
| 备份/同步类工具会看到一次权限变化 | 一次性噪音,不是问题 |
| 撤销范围要注意 | 撤掉上一级那行后,子目录上的继承副本会消失;但诊断脚本在某个具体目录上加的那条独立、不带继承的行不会跟着消失,得单独撤 |
| 这是机器级设置,不进 git | 换机器或重装系统后要重做 |
7.4 写名字还是写号码?
前提是能解析到同一个号码。 实测:
'<账号>' → S-1-5-21-…-1001 ✅ 和目标一致
'<机器名>\<账号>' → S-1-5-21-…-1001 ✅ 一致
'*S-1-5-21-…-1001' → 走通了(只是卡在沙箱的写入授权上)
'*S-1-5-21-0-0-0-9999'(伪造的) → No mapping between account names and security IDs was done
最后一行很说明问题:写号码也不是随便写个数就行,它同样要被校验成真实账号。所以两种写法都要"解析",只是一个从名字找号码、一个拿号码核对。
差别只在三处:
| 名字 | 号码 | |
|---|---|---|
| 歧义 | 光写账号名时,优先匹配本机账号;如果机器加入了域且域里有同名账号,可能指错 | 没有歧义 |
| 重装系统后 | 名字多半还在,命令还能用 | 号码会变 → 报 1332(安全地失败,不会误授权) |
| 改账号名 | 得改命令 | 不受影响 |
建议:要复制进文档或脚本,用带机器名/域名的完整名字;要绝对精确、不在意以后能不能复用,就用 *号码。
7.5 另外三种选择
| 方案 | 适合什么情况 | 代价 |
|---|---|---|
| 专门开一个父目录 | 只给"专门交给 DSH 用"的目录授权(比如 D:\Projects\_workspaces),工作区都放里面 |
影响范围最小,但你得记得把项目放进去 |
| 工作区放用户目录 | 直接放 %USERPROFILE% 下面 |
零改动(那里天然有可继承的完全控制);缺点是项目不在你习惯的位置 |
| 每次跑诊断脚本 | 偶尔才新建工作区 | 只动那一棵,自带备份和回滚命令;但每次多一步 |
八、顺带一个坑:工具缓存默认写在用户目录
排查时还会撞上同一套边界的另一面:语言和包管理器的缓存默认都在用户目录里。
8.1 现象
# 缓存是热的时候,只是噪音:
go: writing stat cache: open <模块缓存路径>\...tmp: Access is denied.
# 缓存是冷的时候,直接失败:
failed to initialize build cache at <构建缓存路径>: mkdir <构建缓存路径>: Access is denied.
(退出码 1,缓存目录都没建起来)
这解释了一个很容易被骗的假象:某次构建"成功"了,就以为受限会话没问题——其实那只是因为缓存命中(命中只需要读,不需要写)。只要你改一行代码、需要写新缓存,就会失败。
8.2 为什么
可写的地方只有两个:
- 工作区根目录;
- 会话私有的临时目录(进程启动前
TMP/TEMP已经改到那里;注意它是会话级、会轮换、释放时删除的)。
而 Go 默认把缓存放在 %USERPROFILE%\go\pkg\mod 和 %LOCALAPPDATA%\go-build,都在边界之外。
这不是 Windows 独有:Linux/macOS 后端同样只允许"工作区根 + /tmp"(不同后端细节不同),所以 $HOME/go/pkg/mod、$HOME/.cache/go-build 一样写不进去。
8.3 怎么办
推荐:把缓存指到工作区里面。
$env:GOCACHE = "$PWD\.cache\go\build"
$env:GOMODCACHE = "$PWD\.cache\go\mod"
go build ./...
再配一条 gitignore(.cache/)。实测:冷构建缓存放工作区外 → 失败退出;放工作区内 → 成功,并写进了上千个缓存文件。好处是跨会话保留、不用审批、不碰任何权限设置。
次选:指到 TEMP。 零配置(那里天然可写),但不持久——会话一轮换或 DSH 一重启就没了,大项目不划算。
再次:按命令解除沙箱。 适合偶尔一次的大动作(首次拉全量依赖、装工具链),但那一条命令没有隔离,而且要人工批准。
8.4 两个"此路不通"
- 没有"额外可写目录"这个配置项。 策略层写明了:每个会话只有一个主要工作区根。
- 别想用符号链接、目录联接绕。 检查发生在目标对象上,不在路径上,联接只是换个名字指过去。
九、常见误解六个
1. 目录是我的,为什么不能改它的权限? 不是不能改,是改的项不对。房主白送"改名单",你缺的是"改房主登记",而沙箱的合并写入需要它。
2. 为什么 C 盘行、D 盘不行?
不是盘符。系统盘根目录、C:\Users 本身也缺 WO;真正的差别是用户目录上有一条显式的、可继承的完全控制,所以它下面的新目录天然满足。
3. 加了权限,会不会让沙箱里的程序写到更多地方? 不会。子进程能写什么,取决于它那张通行证和 Low 标签。边界没变。
4. 会不会把整个盘变成低等级保护区? 不会。Low 标签只贴在真正当工作区用的那一棵上,只往下传。
5. 修好过一个目录,为什么换新目录又不行? 诊断脚本补的那行不带继承标记,只作用于那一个目录。要么在上一级做可继承授权,要么每个新工作区再修一次。
6. 删除重建、改名、跨盘移动之后还要重修吗?
| 操作 | 要重修吗 |
|---|---|
| 同一个盘内改名或移动 | 不用(权限跟着对象走) |
| 删掉再建同名目录 | 要(显式行随对象没了) |
| 重新克隆/复制到新目录 | 要(新目录继承的是上级那套) |
| 跨盘移动或复制 | 看目标盘,可能要 |
| 在已修好的工作区里新建子目录 | 不用(沙箱的常驻授权会往整棵树传播) |
十、收尾
三句话记住这件事:
- 权限不是"能不能用这个目录",而是"谁、以什么身份、能改哪些项"。
Modify、WRITE_DAC、WRITE_OWNER是三样不同的东西,但报错都是同一句"拒绝访问"。 - 房主身份不是万能钥匙。 它只送你"改名单",不送"改房主登记"——而现代沙箱恰恰需要后者来写完整性标签。
- 别靠读名单猜,直接问内核。 一次真实的访问探测,胜过一小时的人肉推理。
下次再遇到类似问题,按这个顺序走:
- 先分清:是"命令失败",还是"命令根本没启动"?(
grantWrite/SetNamedSecurityInfoW属于后者) - 换个位置试(用户目录 vs 数据盘),看能不能复现
- 用自检脚本看
WRITE_DAC/WRITE_OWNER这两项 - 缺
WO→ 跑一次诊断脚本(在解除沙箱的会话里,但不需要提权) - 以后还会新建工作区 → 在上一级目录做一次可继承授权(
(OI)(CI)(WO,WDAC)),并留好撤销命令 - 顺手把语言/包管理器的缓存指到工作区里面,避免"热缓存能过、冷缓存必挂"的假象