fix(core): honor ACCEPT_EDITS working-directory auto-allow for edit tools and unify preset-aware permission input - #2871
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
| } | ||
| Path existing = normalised; | ||
| Deque<Path> remainder = new ArrayDeque<>(); | ||
| while (existing.getParent() != null && !Files.exists(existing)) { |
There was a problem hiding this comment.
[P1] 悬空符号链接可以把写入带到工作目录外
Files.exists() 会跟随链接;当工作目录内的链接指向尚未创建的外部文件时,它返回 false,随后代码把链接当成普通路径重新拼回,错误判定为工作目录内。
我用真实 WriteFileTool 复现:权限结果为 ALLOW,随后成功在工作目录外创建了目标 .bashrc。
建议使用 NOFOLLOW_LINKS 识别每一层符号链接,解析不了、悬空或循环时 fail closed,不能自动 ALLOW。
|
|
||
| private static Path resolveAgainstWorkingDirs(String filePath, List<String> dirPaths) { | ||
| Path raw = Path.of(expandTilde(filePath)); | ||
| Path absolute = raw.isAbsolute() ? raw : Path.of(expandTilde(dirPaths.get(0))).resolve(raw); |
There was a problem hiding this comment.
[P1] 权限检查与工具执行使用了不同的相对路径基准
权限端把相对路径固定解析到“第一个 working directory”;但:
- WriteFileTool 实际按自己的 baseDir 或 JVM CWD 解析;
- FilesystemTool 还会经过 WorkspacePathNormalizer 和具体 AbstractFilesystem 的路径策略。
实测 working directory 为 /tmp/allowed、工具 baseDir=/tmp/actual 时,created.txt 被判定 ALLOW,实际写进 /tmp/actual/created.txt。
通用 ToolBase 不应猜测执行基准。应由工具提供与真实执行完全一致的无副作用 path resolver;无法确认时,相对路径只能 PASSTHROUGH/ASK。
| } | ||
| List<String> paths = new ArrayList<>(filePathParams.size()); | ||
| for (String param : filePathParams) { | ||
| Object val = toolInput.get(param); |
There was a problem hiding this comment.
[P1] 权限检查没有包含 preset 参数
权限门检查的是模型原始输入,而 ToolExecutor 在之后才把 preset 参数合并进去。比如:
- filePathParams={"src","dst"}
- 模型传入工作目录内的 src
- dst 是注册时 preset 的工作目录外路径
当前代码忽略缺失的 dst,按 src 返回 ALLOW,实际方法却会收到外部 dst。
权限检查必须使用与执行阶段完全一致的 effective input,包括 preset 覆盖规则;声明路径缺失或类型不符时也不能按剩余子集自动放行。
| } | ||
| Path existing = normalised; | ||
| Deque<Path> remainder = new ArrayDeque<>(); | ||
| while (existing.getParent() != null && !Files.exists(existing)) { |
There was a problem hiding this comment.
Blocking — dangling symlink escapes the working scope.
Files.exists(Path) follows links and returns false for a symlink whose target does not exist, so this loop walks past the link to its parent, resolves the parent, and re-appends the link name. A dangling symlink therefore resolves inside the working directory while the actual write follows it and lands outside.
Verified on macOS/JDK:
scope/escape -> /tmp/outside_target (target does not exist)
Files.exists(p) = false <- loop walks up
Files.exists(p, NOFOLLOW_LINKS) = true
Files.isSymbolicLink(p) = true
resolveReal => /private/tmp/scope/escape startsWith(scope) == true -> ALLOW
Files.writeString("scope/escape", ...) => creates /tmp/outside_target
The existing SymlinkResolution tests only cover links whose target already exists, so they miss this branch.
Suggested fix: walk with Files.exists(existing, LinkOption.NOFOLLOW_LINKS) and, whenever a component is a symbolic link, resolve it explicitly (Files.readSymbolicLink + recursive resolution). If the link cannot be resolved (dangling, cyclic, unreadable), fail closed — return the unresolvable marker so isPathInWorkingScope yields false and the engine falls back to ASK. A regression test for the dangling case would also close the gap.
|
|
||
| // 1. Any dangerous path -> Safety-ASK (bypass-immune in the engine) | ||
| for (String filePath : filePaths) { | ||
| if (isDangerousPath(filePath)) { |
There was a problem hiding this comment.
The two checks use different anchors for relative paths, which makes them inconsistent:
isDangerousPath(filePath)->Path.of(expandTilde(p)).toAbsolutePath()resolves against the JVM CWD.isPathInWorkingScope(p, context)->resolveAgainstWorkingDirsresolves againstworkingDirsentry 0.
When CWD and workingDirs[0] differ, a relative argument can pass the dangerous check under one anchor and be auto-allowed under the other. Minimal example: workingDirs[0] = ~/.ssh, argument "id_rsa" -> dangerous check evaluates ~/project/id_rsa (not dangerous) while the scope check evaluates ~/.ssh/id_rsa (in scope) -> ALLOW on a dangerous file.
The same mismatch exists against the tool layer: WriteFileTool resolves relative paths against its own baseDir and the harness write_file/edit_file resolve against the workspace, neither of which is workingDirs[0]. The permission grant is then computed for a path the tool never writes to.
Suggested fix: resolve each declared path once into a single normalised+real Path (relative -> workingDirs[0], matching the documented rule), then run both the dangerous check and the containment check against that one resolved value. That also lets isDangerousAbsolute be reused directly on the resolved path instead of re-deriving it from the raw string.
| if (Files.exists(normalised)) { | ||
| try { | ||
| return normalised.toRealPath(); | ||
| } catch (IOException ignored) { |
There was a problem hiding this comment.
Fail-open on resolution error: both catch (IOException ignored) blocks return the lexical path when toRealPath() fails (permission denied on an ancestor, transient FS error, symlink loop). The scope check then compares unresolved paths and can grant ALLOW for a path that in reality points outside.
For a permission decision the safe default is the other way round. Please have resolveReal signal "unresolvable" (e.g. Optional.empty() or a sentinel) and make isPathInWorkingScope return false in that case — the engine then falls back to ASK, which is the same outcome as today for any path outside the scope.
Aias00
left a comment
There was a problem hiding this comment.
The design is the right one: keeping the engine tool-agnostic and putting a declarative filePathParams contract on the tool side is much better than the engine guessing parameter names, and the "declare nothing -> plain PASSTHROUGH" fallback makes this a no-op for every existing tool. The dangerous-path check running before the auto-allow, the all-must-be-in-scope rule for multi-path tools, and the never-implicitly-authorise-CWD decision are all correct. Docs in en+zh and the 32 tests are in good shape.
I am requesting changes on one thing, because this code turns a silent ASK into an ALLOW and therefore has to be tight: the symlink hardening has a hole for dangling symlinks. Files.exists follows links and returns false when the target does not exist, so resolveReal walks past the link, resolves the parent, re-appends the name, and reports the path as inside the scope — while Files.writeString follows the link and creates the file wherever it points. I reproduced it locally (details inline). The two symlink tests only cover links whose target already exists, so the branch is untested.
Two further points inline, non-blocking on their own but worth fixing in the same pass: the dangerous-path check and the scope check resolve relative paths against different anchors (JVM CWD vs workingDirs[0]), which also diverges from how WriteFileTool (baseDir) and the harness tools (workspace) resolve them; and resolveReal fails open when toRealPath() throws, where fail-closed is the right default for a permission decision.
Once the dangling-symlink case is closed with a regression test, I am happy to approve.
…tools ACCEPT_EDITS promised to auto-allow file edits inside working directories (PermissionMode javadoc, AdditionalWorkingDirectory, permission-system docs), but the engine's ACCEPT_EDITS branch only handled read-only tools and nothing consumed PermissionContextState#getWorkingDirectories, so edit tools always fell through to the default ASK (silently DENY under DONT_ASK). Fixes agentscope-ai#2870. Fix it with a tool-side path contract instead of engine-side parameter-name guessing: tools declare which parameters carry file paths via @tool(filePathParams = {...}) or ToolBase.builder().filePathParams(...), and the default ToolBase#checkPermissions evaluates the declared paths: any dangerous path -> Safety-ASK (bypass-immune); ACCEPT_EDITS + all paths inside a working directory -> ALLOW; anything else -> PASSTHROUGH to the engine's rule tables and mode defaults. - ToolBase: filePathParams field/builder/positional ctor + path-aware default checkPermissions with tilde expansion, lexical normalisation, symlink resolution (nearest-existing-ancestor for new files) and multi-path all-in-scope semantics; PermissionEngine untouched - ReflectiveFunctionTool: bridge @tool#filePathParams (duplicates collapse) - WriteFileTool write_text_file/insert_text_file and harness FilesystemTool write_file/edit_file declare their path parameters - tests: engine ACCEPT_EDITS group, ToolBaseFilePathParamsTest (symlink / escape / tilde / multi-path / backward compat), annotation bridge regression guards, harness end-to-end permission test - docs: filePathParams section in permission-system.md and @tool attribute table (en + zh)
24694aa to
ec90e8d
Compare
|
@guslegend0510 感谢您细致的评审,对应修复已全部实现并推送(ec90e8d),逐条回应如下: [P1] 悬空符号链接逃逸(ToolBase L381) — 已修复。 [P1] 权限与执行的相对路径基准不一致(L357) — 已按您的建议实现"工具提供无副作用 resolver"契约:新增 [P1] 权限检查未包含 preset 参数(L308) — 已修复。合并逻辑收敛为 验证:agentscope-core 与 agentscope-harness 全量测试通过(harness 845 tests,0 失败), 再次感谢您花时间逐条验证并给出如此具体的修复方向,期待您的再次 review。 |
|
@Aias00 Thanks for the thorough review — and for the kind words on the overall design. All three points are addressed and pushed (ec90e8d). Blocking — dangling symlink escape. Fixed with a component-wise resolver ( Anchor mismatch between the dangerous check and the scope check. The dangerous check now evaluates both anchors for the same raw argument — the JVM-CWD-anchored lexical form and the tool-resolved execution landing point — with each candidate symlink-resolved before matching, so a relative argument whose CWD form looks harmless cannot be auto-allowed against a dangerous resolver landing. Covered by Fail-open on resolution errors. While in there, the permission gate and Verification: full agentscope-core and agentscope-harness suites pass (harness: 845 tests, 0 failures); You mentioned you'd be happy to approve once the dangling-symlink case is closed with a regression test — that's now in place. Would appreciate another look when you have a moment. |
| return null; | ||
| } | ||
| current = current.resolve(name); | ||
| if (!Files.isSymbolicLink(current)) { |
There was a problem hiding this comment.
The POSIX semantics here are right, and I re-ran my original repro against this exact resolver: dangling->outside = refused, dangling->inside = allowed, cycle = null, symlinked-dir/new-file = refused, plain-new-file = allowed, ../ = refused (6/6).
One platform gap worth a follow-up (not blocking): on Windows, Files.isSymbolicLink only reports true symlinks. Directory junctions and other reparse points are commonly not reported as symbolic links, and 8.3 short names (C:\\PROGRA~1\\...) resolve to a different lexical string than the configured working directory — both would slip past this walk and past the startsWith comparison. A Files.isSymbolicLink(current) || Files.readAttributes(current, BasicFileAttributes.class, NOFOLLOW_LINKS).isOther() check would cover junctions; short names would need getRealPath/toRealPath on the existing prefix. Given ACCEPT_EDITS is most used on developer workstations (including Windows, per the issue reporter), a short "Windows reparse points are not expanded" note in the javadoc or docs would at least keep the limitation visible.
| * that cannot be resolved with certainty (cycle, unreadable link) is treated as | ||
| * dangerous (fail closed). | ||
| */ | ||
| protected boolean isDangerousPath(String filePath) { |
There was a problem hiding this comment.
Heads-up for the release notes: this is a behaviour change on a protected extension point, not just an internal helper. isDangerousPath now returns true for any path that cannot be symlink-resolved with certainty (cycle, unreadable link) and for InvalidPathException, where it previously returned false.
That is the right direction for a security check, and I confirmed no production caller in this repo depends on the old result (only tests do). But downstream subclasses that call isDangerousPath to decide whether to block an operation will now see true more often — for example a path containing an unreadable symlink component. Worth an @since/javadoc line stating the fail-closed contract so the change is discoverable at the API level, not only in the docs.
Aias00
left a comment
There was a problem hiding this comment.
The blocker is closed. I extracted resolveEffective and re-ran it against the exact repro from my previous review:
| case | before | now |
|---|---|---|
| dangling symlink -> outside target | ALLOW (escape) | refused |
| dangling symlink -> inside target | ALLOW | ALLOW (matches kernel semantics) |
| cyclic A->B->A | n/a | null -> fail closed |
| new file through symlinked dir | refused | refused |
| plain new file inside scope | ALLOW | ALLOW |
../ escape |
refused | refused |
Component-wise expansion with readSymbolicLink, a branch-local linkChain for cycle detection and a 256-step cap is the right shape, and the regression matrix covers precisely the branch that was missing.
The anchor mismatch is also genuinely resolved — and better than the fix I suggested. Rather than picking one anchor, resolveExecutionPath makes the tool prove its landing point and returns Optional.empty() when it cannot, so relative paths simply never auto-allow. Combined with isDangerousPath evaluating both the CWD-anchored and the resolver-anchored form, the two checks can no longer disagree. Fail-closed on null, plus the RuntimeException -> Safety-ASK wrapper, closes the fail-open path as well.
Two things I particularly appreciate:
- The preset-parameter unification is a real find. The gate previously evaluated the raw input while
ToolExecutormerged presets, so a preset file-path parameter was invisible at permission time. Routing both throughToolRegistry.effectiveInputis the correct fix, andReActAgent/ToolExecutorstaying consistent is essential for this whole check to mean anything. - Documenting the harness caveat rather than papering over it.
FilesystemToolreturning empty for every path meanswrite_file/edit_filestill ASK underACCEPT_EDITS, so #2870 is only fixed for tools that can prove their landing point (the coreWriteFileToolamong them). Fail-closed is the right call givenAbstractFilesystemroots depend on the runtime context, and the en/zh docs now say so explicitly — that honesty is worth more than a guessy auto-allow.
CI is green on both platforms.
Two non-blocking items inline: Windows junctions / 8.3 short names are not covered by isSymbolicLink+startsWith (worth a doc note), and isDangerousPath now fails closed on unresolvable paths, which is a behaviour change on a protected extension point that deserves an @since line.
One process note: the PR has grown from ~1.2k to ~2.1k lines and now carries two distinct changes — the filePathParams gate and the preset-input unification. Both are warranted and I am approving as-is, but please make sure the squash-merge message calls out the preset fix, since it is a security fix in its own right and would otherwise be invisible in the changelog.
Thanks for the thorough follow-through.
|
@Aias00 Thanks for the approval, and for re-running your repro against the new resolver — really appreciated. Two updates on your notes:
Thanks again for the thorough follow-through. Also thanks @guslegend0510 for the LGTM! |
AgentScope-Java Version
2.0.3-SNAPSHOT
Description
Fixes #2870.
Background:
ACCEPT_EDITSwas documented (javadoc onPermissionMode/AdditionalWorkingDirectory, pluspermission-system.mdin en/zh) to auto-allow file edits inside working directories, but the engine'sACCEPT_EDITSbranch only handled read-only tools and nothing ever consumedPermissionContextState#getWorkingDirectories(), so edit tools always fell through to the default ASK — silently DENY underDONT_ASK. During review a second, independent issue surfaced: the permission gate evaluated the raw model input whileToolExecutormerges preset parameters, so a preset-supplied file path was invisible at permission time.Changes:
@Tool(filePathParams = {...})/ToolBase.builder().filePathParams(...), bridged byReflectiveFunctionTool. The defaultToolBase#checkPermissionsthen applies strict extraction (missing/blank declared param → PASSTHROUGH, never a subset auto-allow; non-string value → bypass-immune Safety-ASK), a dangerous-path check, and theACCEPT_EDITSworking-directory check (ALLOW only when every declared path provably resolves inside the scope).ToolFilePathResolver+ theToolBase.resolveExecutionPathhook so a tool proves its own execution landing point instead of the framework guessing. Relative paths are never auto-allowed without a resolver.WriteFileToolimplements the resolver viaFileToolUtils.resolveLexical, whichvalidatePathnow shares so permission and execution cannot drift apart. The harnessFilesystemToolcannot prove its landing point (AbstractFilesystemroot/mode/namespace depends on the runtime context), so it fails closed:write_file/edit_filefall back to ASK underACCEPT_EDITS— documented in en/zh as a known caveat rather than papered over.resolveEffective) that mirrors kernel semantics: dangling links are expanded to their targets (outside scope → refused; inside scope → allowed), and cycles, unreadable links and chains over 256 steps returnnulland fail closed — the scope check refuses them and the dangerous check treats them as dangerous. The dangerous check evaluates both the JVM-CWD-anchored form and the tool-resolved landing point, and the whole self-check converts unexpected runtime failures into a bypass-immune Safety-ASK.ToolRegistry.effectiveInputis now the single merge source (preset wins), used by bothToolExecutorand theReActAgentpermission gate, so permission evaluates exactly the input that will be executed. This is a security fix in its own right.filePathParamssection inpermission-system.mdand the new@Toolattribute row intool.md.How to test:
mvn -pl agentscope-core test -Dtest='ToolBaseFilePathParamsTest,PermissionEngineTest,ReflectiveFunctionToolTest'— 36-caseToolBaseFilePathParamsTest(dangling/cyclic/chained symlinks, dual-anchor dangerous landing, resolver-gated relative paths, strict extraction, exception fail-closed), the engineAcceptEditsWorkingDirgroup, and annotation-bridge guards through a realToolkitregistration.mvn -pl agentscope-core test -Dtest='PresetPermissionGateTest'— preset dst outside the scope pauses the agent withPERMISSION_ASKINGinstead of auto-allowing; preset dst inside executes normally; merge semantics (preset wins, fresh map).mvn -pl agentscope-harness -am test -Dtest='FilesystemToolPermissionTest' -DfailIfNoTests=false— harness end-to-end: in-scope write falls back to ASK because the resolver is opaque; outside write asks;.bashrcedit under BYPASS stays bypass-immune Safety-ASK.mvn -pl agentscope-harness -am testpasses locally (core + harness, 0 failures; harness: 847 tests, 3 pre-existing skips).Checklist
Please check the following items before code is ready to be reviewed.
mvn spotless:apply—spotless:checkpasses on both modulesmvn test) — core + harness full suites green (harness: 847 tests, 0 failures); the full-reactor build runs in CImvn -pl agentscope-core javadoc:javadocpasses with 0 errorspermission-system.md(en/zh) documentsfilePathParams, the resolver contract and the harness caveat;tool.md(en/zh) registers the new@Toolattribute