解决什么问题Agent 的read_file类工具只做normalize startsWith在 Windows 上会被 junction、8.3 短名、硬链接从沙箱里带出去。本文给出一个纯 JDK 17 的四层文件沙箱、29 条真实攻击语料以及「哪一层拦住了什么、还剩哪一条拦不住」的实测答案。适合谁读在做 Agent 工具网关、MCP server、代码执行沙箱的 Java 后端工程师以及需要给模型权限画边界的人。能收获什么一套能直接编译运行的沙箱实现javac/java全跑通、Windows 路径语义的四个具体坑与对应 API 真相、TOCTOU 窗口的实测微秒数、四层防御的每调用开销以及一条「Java 层做不到、必须交给 OS」的硬边界。近一周的公开报道里Agent 的权限边界被反复提起Codex CLI 被曝自 0.157.0 起默认拉起常驻守护进程issue 冲上 Hacker News 热榜Anthropic 披露内部评测中的自主 Agent 多次越界随后切断了评测环境的实时联网微软则把 Windows 上面向 Agent 的隔离执行容器 MXC 推向正式可用。这三件事指向同一个判断给 Agent 的能力加护栏正在从「加分项」变成「上线前置条件」。而 Java 后端最常见的护栏就是那个看起来最简单、也最容易写错的东西——文件路径沙箱。我自己写了个沙箱造了 29 条语料在 JDK 17.0.20 / Windows 11 上真跑了一遍。结论不太好听常见的写法漏 3 条四层防御漏 1 条而那 1 条不是「再加一层」能补的。一、实验台一个能编译运行的四层沙箱先说清楚口径。以下所有数字都来自本机执行环境实采如下由System.getProperty与FileStore打印不是手填项实测值JDKjava version 17.0.20 2026-07-21 LTSbuild 17.0.207-LTS-191编译器javac 17.0.20操作系统os.name Windows 11os.version 10.0os.arch amd64卷NTFS支持的属性视图[owner, dos, acl, basic, user]沙箱根...\demo17\run\ws敏感目标...\demo17\run\outside\creds\key.txt内容标记OUTSIDE-SECRET-DO-NOT-READ-1f9c沙箱分四层每层只干一件事public Decision check(String raw) { // ---- L0 语法层字符与命名空间形态先于任何文件系统调用 ---- if (raw.indexOf(\0) 0) return Decision.deny(Layer.L0_SYNTAX, NULL_BYTE, 含 NUL); String low raw.toLowerCase(Locale.ROOT); if (low.contains(%2e) || low.contains(%2f) || low.contains(%5c) || low.contains(%25)) return Decision.deny(Layer.L0_SYNTAX, ENCODED_TRAVERSAL, 疑似 URL/双重编码穿越); if (raw.startsWith(\\\\)) { if (raw.startsWith(\\\\.\\) || raw.startsWith(\\\\?\\)) return Decision.deny(Layer.L0_SYNTAX, DEVICE_OR_EXTENDED_NS, Win32 设备/扩展命名空间); return Decision.deny(Layer.L0_SYNTAX, UNC, UNC 路径); } if (raw.indexOf(*) 0 || raw.indexOf(?) 0) return Decision.deny(Layer.L0_SYNTAX, WILDCARD, 含通配符可能被 shell 展开); Path candidate (isAbsolute(raw) ? Paths.get(raw) : rootGiven.resolve(raw)).normalize(); // ---- L1 词法层归一化之后必须仍在根前缀内 ---- if (!candidate.startsWith(rootGiven.normalize())) return Decision.deny(Layer.L1_LEXICAL, PREFIX_MISS, 归一后越出根 candidate); // L2 / L3 见下文 }L2 是整套防御的重心别看字符串问内核。关键在于toRealPath()会跟随 junction、解析 8.3 短名、吃掉尾点而normalize()只会天真地删..。/** 对最深「已存在」祖先做 toRealPath尾部不存在的名字原样拼回。 */ static Path canonicalize(Path candidate) throws IOException { ListString tail new ArrayList(); Path p candidate; while (p ! null !Files.exists(p, LinkOption.NOFOLLOW_LINKS)) { Path name p.getFileName(); if (name ! null) tail.add(0, name.toString()); p p.getParent(); } if (p null) throw new IOException(no existing ancestor); Path real p.toRealPath(); // 跟随链接解析短名/尾点/大小写 for (String s : tail) real real.resolve(s); return real; }拿到real之后再做一次前缀比较并拿真实路径去撞名字黑名单——顺序很重要先撞字符串黑名单会被短名绕过下面有实测。L3 则处理「链指进根内」这种 L2 放行的情况。二、29 条语料谁漏了漏在哪语料分五组正常路径测误杀、穿越、编码/畸形、链接、名字黑名单。判定为放行的样本会真的去读文件用读到的内容判逃逸不看返回值。语料表11 条最能说明问题的样本#输入词法黑名单基线基线读到四层沙箱1data/note.txtALLOWinside-ok-alphaALLOW5DATA/NOTE.TXTALLOWinside-ok-alphaALLOW6../../outside/creds/key.txtDENY L1—DENY L111%2e%2e%2f...key.txtALLOWNoSuchFileExceptionDENY L013data/note.txt\0.exeDENY L0—DENY L0 NULL_BYTE17\\?\C:\Windows\win.iniDENY L0—DENY L020data/note.txt:hiddenDENY L0—DENY L0 ADS22link/key.txtjunction 指根外ALLOWLEAKDENY L2 CANONICAL_ESCAPE23pub.key硬链接指根外ALLOWLEAKALLOW →LEAK24selflink/note.txt根内 junctionALLOWinside-ok-alphaDENY L3 REPARSE27SECRET~1/key.txt8.3 短名绕黑名单ALLOWLEAKDENY L2 NAME_BLOCK_ON_CANONICAL汇总数字攻击样本 24 条基线放行 9 条、真实逃逸 3 条四层沙箱放行 4 条、真实逃逸 1 条。正常路径 5 条两种实现都是零误杀。各层落点L0 拦 8 条、L1 拦 6 条、L2 拦 5 条、L3 拦 1 条、放行 4 条。被放行的 4 条里有 3 条其实读不出东西..;/、全角/、以及创建失败的符号链接它们在沙箱眼里就是「根内一个不存在的文件」最终NoSuchFileException。这里要区分两个常被混为一谈的指标放行不等于逃逸。安全设备上「放行数」是噪声「真读到根外字节」才是事故。三、Windows 路径语义的四个真相三种「链」里普通权限能造出来的正好是杀伤力最大的两种mklink /Jjunction和mklink /H硬链接都成功了而创建真符号链接被系统直接拒——cmd返回「客户端没有所需的特权」Files.exists(sym.txt, NOFOLLOW)为 false。所以下表只列我实测到的两列符号链接那一列我没有未测先写。API / 手段junctionws\link→根外硬链接ws\pub.key→根外文件Files.isSymbolicLinkfalsefalseFiles.readSymbolicLinkNotLinkException: Reparse point is not a symbolic linkNotLinkException: 此文件或目录不是一个重分析点。Files.getAttribute(dos:reparsePoint)IllegalArgumentException: reparsePoint not recognized同左Files.getAttribute(dos:directory)IllegalArgumentException: directory not recognized同左toRealPath()解析到目标目录越界路径本身不变仍显示在根内Files.isSameFile(它, 目标)truetrue普通权限可否创建能能第一isSymbolicLink对 junction 返回 false想靠它挡链接等于没挡。第二我原本以为dos:reparsePoint能拿到重解析点标志实测IllegalArgumentException——这台 JDK 17.0.20 的DosFileAttributeView只认readonly我顺手验了dos:readonly false是可用的别照抄脑子里的 API 表。第三真正能用的信号有两个readSymbolicLink抛出的异常文案里带 Reparse point这句是 JDK 硬编码的英文和系统本地化的「不是一个重分析点」可区分以及toRealPath()与normalize()的字符串比较。static String linkKind(Path p) { if (Files.isSymbolicLink(p)) return symlink; try { Files.readSymbolicLink(p); return symlink; } catch (java.nio.file.NotLinkException e) { if (String.valueOf(e.getMessage()).contains(Reparse point)) return junction(重解析点非符号链接); } catch (IOException | RuntimeException e) { return null; } try { Path real p.toRealPath(), abs p.toAbsolutePath().normalize(); if (!real.toString().equalsIgnoreCase(abs.toString())) return real!lexical; } catch (IOException ignore) { return null; } return null; }还有一个坑值得单独点名L3 必须扫描「调用方给的那条路径」不能扫 L2 解析后的真实路径。我第一版写的就是firstReparseOnChain(real)结果selflink/note.txt永远扫不到——toRealPath已经把selflink这一段吃掉了链只存在于原始candidate里。表现就是 L3 拦停数恒为 0看着「四层齐全」其实第三层是个摆设。顺带一条容易被忽略的边界Path.startsWith在 Windows 上是大小写不敏感的WindowsPath.compareTo语义所以DATA/NOTE.TXT这类变体不需要自己toLowerCase反过来如果你在 Linux 容器里跑同一套代码大小写敏感白名单语义会跟着变。同一份沙箱代码换个 OS 就不是同一个安全边界。四、硬链接四层全过Java 层无解ws\pub.key是指向outside\creds\key.txt的硬链接。四层沙箱的判定是 ALLOW而且读得到内容四层沙箱判定 ALLOW 经 ws\pub.key 读到 OUTSIDE-SECRET-DO-NOT-READ-1f9c 经 outside\... 读到 OUTSIDE-SECRET-DO-NOT-READ-1f9c isSameFile(pub.key, target) true原因很干净硬链接不是链接它是同一个文件记录上的第二个名字。它的规范路径确确实实在根内所以 L2 没理由拒绝它不是重解析点L3 也看不见。更糟的是「打开后再复检一次」也没用——readChecked走完 L0→L3 两次两次都通过。唯一能在路径维度识破它的是Files.isSameFile但它要求你已经知道可疑目标把整个磁盘的文件都比对一遍显然不现实。所以这里必须承认 Java 层的尽头权限属于文件对象不属于路径字符串。我用纯 JDK 的AclFileAttributeView给真实文件加了一条 DENYDENY插在原 ACL 最前面测完还原AclFileAttributeView view Files.getFileAttributeView(target, AclFileAttributeView.class, LinkOption.NOFOLLOW_LINKS); ListAclEntry original new ArrayList(view.getAcl()); AclEntry deny AclEntry.newBuilder() .setType(AclEntryType.DENY) .setPrincipal(Files.getOwner(target)) .setPermissions(EnumSet.of(AclEntryPermission.READ_DATA)) .build(); ListAclEntry next new ArrayList(); next.add(deny); next.addAll(original); view.setAcl(next); // 之后无论走 pub.key 还是 creds\key.txt读都被内核拒实际运行结果ACL 随后被还原还原后硬链接重新可读 31 字节principal DESKTOP-RAQBN1J\Admin原 ACL 条数 3 已写入 DENY ACEDESKTOP-RAQBN1J\Admin:READ_DATA:DENY 读硬链接被拒 - java.nio.file.AccessDeniedException: ...\run\ws\pub.key 读原路径也被拒 - AccessDeniedException一条 ACE 顶掉 Java 层十层。工程含义很直接沙箱的正解是「Agent 用独立账号跑 根外目录不给读权限」Java 里的这四层只是纵深防御的第二道不是第一道。五、TOCTOU窗口能量出来但归不了零三种实现三种结局窗口本身只占「校验完成 → 句柄建立」这一段但三种用法给出的结局完全不同把check的判定结果缓存性能优化里太常见了窗口从微秒被拉长成 TTL。实测里我先在link - public时缓存判定再把link改指到creds然后按缓存的「放行」结论去读调用方给的原始路径——读到OUTSIDE-SECRET-DO-NOT-READ-1f9c全程零次重新校验。这不是抢概率是必漏。顺带一个反直觉分支如果缓存的是「判定 规范路径」则读回来的是inside-public-ok没越界但内容已经不是link此刻指向的东西了——安全问题变成了一致性问题。每次读取前重新check窗口只剩「校验完成 → 句柄建立」这一段。3000 个样本实测中位147.6 µsp95 253.7 µsp99 375.6 µsmax 1450.2 µs而攻击者换一次 junctionrmdir /qmklink /J中位456.7 ms。按到达时刻随机粗算单次命中概率约0.00032。这里必须给个折扣换链走的是cmd.exe两次进程启动占了大头直接调 Win32 设置重解析点会快一个量级以上所以真实比值比这更不利。真抢一次后台线程不间断换链前台正常调用 4 秒共956次调用DENY 280 次IO 异常链正在被换掉519 次读到根外内容 1 次0.1046%。窗口确实能抢到——但代价是 54% 的调用直接失败。于是有了个反直觉的结论抢窗攻击在这条路径上不划算牺牲可用性换一次读取成功不如直接改内容或走硬链接。TOCTOU 在文件沙箱里更像成本放大器而不是主攻击面但在「检查一次、长期缓存」的实现里它是必漏项。六、代价四层把 3.9 µs 变成了 4.4 ms安全不能不谈开销。基准口径同一 JVM、单线程、预热 2 轮、采样 9 轮取中位数内循环次数按操作轻重分别取 400含 syscall和 20000纯内存。操作中位 µs/次minmax折算 次/秒naiveLexicalresolvenormalizestartsWith8.2417.6949.834121,338Files.exists(leaf)141.465131.975201.2447,069canonicalize一次 toRealPath文件已存在1,915.81,623.02,361.1522canonicalize尾巴不存在退到父目录938.8802.61,319.11,065check全通过L0→L3 原始实现4,498.23,415.95,324.0222check在 L1 拦停穿越16.66811.93219.25159,995check在 L2 拦停junction1,279.9986.61,542.9781check在 L3 拦停根内 junction1,670.51,202.61,755.2599check结果缓存命中0.3430.0880.4902,916,430check全通过fastLinkScan 优化后1,764.21,609.52,498.65674.5 毫秒里的大头是异常控制流四层比词法基线贵546 倍8.2 µs → 4.5 ms每秒 1000 次工具调用就要吃掉 4.5 秒 CPU。这不是「四层不能要」而是我的实现先要修4.5 ms 里的大头是L3 逐段调用readSymbolicLink每一次非链路径都要抛一个NotLinkException——用异常做控制流在 Windows 上就是毫秒级的。把 L3 改成「先比candidate与real的字符串相同就直接跳过扫描」实测4,498 µs → 1,764 µs省 61%且 29 条语料的判定一条没变。剩下的量级仍然贵一次toRealPath就要 1.9 ms。另外提醒一句抖动同一段naiveLexical代码两次独立运行的中位数分别是 3.9 µs 和 8.2 µs——Windows 文件系统调用的方差就这样别拿单次运行做容量规划。所以落地建议按这个顺序下面这段是配置形态示意不是本文的实测对象agent-file-tool: sandbox: root: D:/agent-ws/task-23817 # 每个任务一个独立根避免任务间互读 resolve-symlinks: reject # L3链一律拒代价是误杀根内合法 junction canonicalize-per-call: true # 禁止缓存 check 结果第五节证明缓存即必漏 os-isolation: dedicated-account # 正解独立账号 根外不给 READ_DATA budget: { max-read-bytes: 1048576, timeout-ms: 2000 }resolve-symlinks: reject要单独提醒一句它是安全换可用性的取舍。我的语料里selflink/note.txt读的是根内合法文件严格模式下被误杀而 monorepo、node_modules、迁移过来的老仓库里根内 junction 非常常见。默认 reject 之前先数一下自己仓库里有多少条路径会因此变成不可用。七、适用边界与风险提示只覆盖读路径。写、删、重命名、创建链接、解压Zip Slip是另一套语义写操作还多了「目录尚不存在」的分支toRealPath会失败本文的canonicalize只能退到「最近已存在祖先」判定精度会掉一档。不含内容级风险。路径合规不代表内容无害符号链接没越界但文件里可能是密钥Prompt 注入也不走文件系统。命令注入同理——把路径拼进 shell 字符串data/x.txt echo INJECTED就能多跑一条命令必须用ProcessBuilder的 argv 形式传参。不含并发与配额。没做软配额、fd 上限、超时强杀这些属于运行时沙箱的活。平台相关。所有结论是 JDK 17.0.20 Windows 11 NTFS 的实测换 Linux 或 exFATisSymbolicLink、大小写敏感、ADS、短名这些行为会一起变语料得重跑。本文不解决「Agent 已经拿到合法内容后怎么办」也不试图给出一条可以宣布「安全」的层数能给的只有「哪一层拦住了多少条、还剩哪一条要靠别人」。AI 辅助说明选题与代码由我确定并在本机全量执行文中数字均为真实运行输出未使用他人文章素材。八、怎么复现以及怎么判定成功cd demo17 javac -encoding UTF-8 -d out Sandbox.java Setup.java Corpus.java OsAcl.java Bench.java Toctou.java Probe.java java -Dfile.encodingUTF-8 -cp out Setup # 建实验台观察 mklink 三条命令的输出 java -Dfile.encodingUTF-8 -cp out Corpus # 29 条语料表 各层落点 java -Dfile.encodingUTF-8 -cp out Toctou # 缓存逃逸 窗口微秒数 4 秒抢窗 java -Dfile.encodingUTF-8 -cp out OsAcl # 三种链的 API 真相 DENY ACE 生效/还原 java -Dfile.encodingUTF-8 -cp out Probe # 逐 API 探 junction本文 API 表的来源判定标准不是「能跑就行」①Corpus汇总里真实逃逸基线 3 条 / 沙箱 1 条且那 1 条必须是pub.key②Probe里 junction 的isSymbolicLinkfalse、readSymbolicLink抛带 Reparse point 的NotLinkException③OsAcl必须看到AccessDeniedException且最后一条是 ACL 还原成功④Toctou走缓存那条要打印出根外内容不缓存那条要DENY L2。四条里任何一条对不上先怀疑实验台被改过比如mklink没权限、目录被清理不要先怀疑结论。九、结论把三件事分开思路会清楚很多字符串层L0/L1能拦住所有「看起来像攻击」的输入但对「词法合法、语义越界」无能为力内核层L2/L3能拦住 junction 和短名但对硬链接无能为力真正能兜住硬链接的只有 OS 层的 ACL 与账号隔离。29 条语料最后剩下的那 1 条恰好落在「Java 写不出防御」的位置上——这不是坏消息它告诉你沙箱该在哪一层收口。如果你的 Agent 工具现在只有normalize startsWith那按本文语料它对link/key.txt、pub.key、SECRET~1/key.txt三条一律放行且真的能读到内容把Probe.java拷进你的项目跑一遍你会知道自己线上依赖的是哪一层。最后想问两个具体问题欢迎在评论区聊你的 Agent 文件工具跑在哪个用户下它现在能不能读C:\Users\你\.ssh以及你有没有缓存过路径校验结果收藏这篇当作 checklist下次加新工具时按 29 条语料自测一遍。