Advisory Details
Title: Workspace-only apply_patch can write outside the workspace through a dangling symlink leaf
Description:
Summary
The experimental apply_patch tool is documented as workspace-contained by default, but its path guard can be bypassed when the target path is a dangling symlink leaf located inside the workspace. In that case, the lexical path check succeeds, the symlink escape check returns early on ENOENT, and the final fs.writeFile(...) follows the symlink target outside the workspace root. A caller that can reach a normal embedded agent session with apply_patch enabled can therefore create or overwrite files outside the intended workspace boundary.
Details
The affected boundary is the embedded coding-tool surface, not the public /tools/invoke HTTP endpoint. createOpenClawCodingTools(...) conditionally exposes apply_patch when tools.exec.applyPatch.enabled is true and the current model is an allowed OpenAI model. The tool is explicitly documented as workspace-contained by default.
The defect sits in the interaction between resolvePatchPath(...) and assertNoSymlinkEscape(...):
resolvePatchPath(...) routes *** Add File: targets through assertSandboxPath(...).
assertSandboxPath(...) first enforces lexical root containment.
assertNoSymlinkEscape(...) then walks path components with lstat().
- If the final leaf lookup hits
ENOENT, the function returns success immediately instead of resolving from the nearest existing ancestor.
applyPatch(...) then performs fs.writeFile(...) on the symlink path, and the OS follows the dangling symlink target outside the workspace.
The result is that a path like jump appears safe because it is lexically inside the workspace, while the actual write lands at the symlink target outside the workspace.
Relevant code:
// src/agents/sandbox-paths.ts
try {
const stat = await fs.lstat(current);
if (stat.isSymbolicLink()) {
const target = await tryRealpath(current);
if (!isPathInside(rootReal, target)) {
throw new Error(...);
}
current = target;
}
} catch (err) {
if (isNotFoundPathError(err)) {
return;
}
throw err;
}
// src/agents/apply-patch.ts
if (hunk.kind === "add") {
const target = await resolvePatchPath(hunk.path, options);
await ensureDir(target.resolved, fileOps);
await fileOps.writeFile(target.resolved, hunk.contents);
}
PoC
Prerequisites
- A deployment that enables
tools.exec.applyPatch.enabled
- An allowed OpenAI / OpenAI Codex model for the affected session
- A session that reaches the embedded coding-tool surface with workspace write access
- A workspace that already contains a dangling symlink leaf pointing outside the workspace root
- Bun and Python 3 available locally to run the provided PoC scripts
Reproduction Steps
- Download the verification harness from: verification_test.py
- Download the matching control from: control-direct-traversal.py
- Download the minimal driver that invokes the real exported tool path from: tool_driver.ts
- From the repository root, run the verification PoC:
python3 llm-enhance/cve-finding/similar/workspace-boundary-bypass/Advisory-GHSA-qcc4-p59m-p54m-dangling-symlink-apply-patch-exp/verification_test.py
- Observe that the script creates a temporary workspace, plants
workspace/jump -> /tmp/.../outside/owned.txt, invokes the real apply_patch tool with *** Add File: jump, and reports that the outside file now contains pwned\n.
- Run the control:
python3 llm-enhance/cve-finding/similar/workspace-boundary-bypass/Advisory-GHSA-qcc4-p59m-p54m-dangling-symlink-apply-patch-exp/control-direct-traversal.py
- Observe that direct
../escape.txt traversal is rejected with Path escapes sandbox root, confirming the bypass is specific to the dangling symlink alias rather than a total absence of path validation.
Log of Evidence
Verification run:
[Verification Mode] Integration-Test
[workspace symlink]
/tmp/ghsa-qcc4-dangling-ehvh1uhc/workspace/jump -> /tmp/ghsa-qcc4-dangling-ehvh1uhc/outside/owned.txt
[patch stdout]
PATCH_OUTSIDE:"pwned\n"
[independent observation]
{"outside_file": "/tmp/ghsa-qcc4-dangling-ehvh1uhc/outside/owned.txt", "contents": "pwned\n"}
[DEFECT CONFIRMED] apply_patch follows a dangling in-workspace symlink and writes outside the claimed workspace-only boundary.
Control run:
[Verification Mode] Integration-Test Control
[control stdout]
CONTROL_BLOCKED:Path escapes sandbox root (/tmp/ghsa-qcc4-control-5o5g3w4h/workspace): ../escape.txt
[independent observation]
{"escaped_path_exists": false, "outside_exists": false}
[CONTROL PASSED] direct traversal stays blocked on the same interface.
Impact
This is a filesystem boundary bypass on the embedded agent tool surface. Any deployment that enables apply_patch and relies on workspaceOnly=true as a safety guarantee can be tricked into creating or overwriting files outside the intended workspace root, as long as the workspace contains a dangling symlink leaf chosen by the attacker-controlled patch path. The impact is not “public unauthenticated gateway takeover,” but it does break the advertised confinement guarantee for a mutating file tool. Depending on what exists outside the workspace, this can corrupt adjacent project files, host-side config, scripts, or other mounted content that operators reasonably believed was out of reach.
Affected products
- Ecosystem: npm
- Package name: openclaw-cn
- Affected versions: <= 0.2.1
- Patched versions:
Severity
- Severity: Medium
- Vector string: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L
Weaknesses
- CWE: CWE-59: Improper Link Resolution Before File Access ('Link Following')
Occurrences
| Permalink |
Description |
|
async function assertNoSymlinkEscape( |
|
relative: string, |
|
root: string, |
|
options?: { allowFinalSymlink?: boolean }, |
|
) { |
|
if (!relative) { |
|
return; |
|
} |
|
const rootReal = await tryRealpath(root); |
|
const parts = relative.split(path.sep).filter(Boolean); |
|
let current = root; |
|
for (let idx = 0; idx < parts.length; idx += 1) { |
|
const part = parts[idx]; |
|
const isLast = idx === parts.length - 1; |
|
current = path.join(current, part); |
|
try { |
|
const stat = await fs.lstat(current); |
|
if (stat.isSymbolicLink()) { |
|
// Unlinking a symlink itself is safe even if it points outside the root. What we |
|
// must prevent is traversing through a symlink to reach targets outside root. |
|
if (options?.allowFinalSymlink && isLast) { |
|
return; |
|
} |
|
const target = await tryRealpath(current); |
|
if (!isPathInside(rootReal, target)) { |
|
throw new Error( |
|
`Symlink escapes sandbox root (${shortPath(rootReal)}): ${shortPath(current)}`, |
|
); |
|
} |
|
current = target; |
|
} |
|
} catch (err) { |
|
if (isNotFoundPathError(err)) { |
|
return; |
|
} |
|
throw err; |
|
} |
|
} |
|
} |
|
assertNoSymlinkEscape(...) walks the candidate path, but returns success on isNotFoundPathError(err) without resolving a dangling final symlink leaf through the nearest existing ancestor. |
|
if (hunk.kind === "add") { |
|
const target = await resolvePatchPath(hunk.path, options); |
|
await ensureDir(target.resolved, fileOps); |
|
await fileOps.writeFile(target.resolved, hunk.contents); |
|
applyPatch(...) uses the validated target path from resolvePatchPath(...) and immediately performs fs.writeFile(...), so any unresolved dangling symlink alias is followed by the OS at write time. |
|
const applyPatchConfig = options?.config?.tools?.exec?.applyPatch; |
|
const applyPatchEnabled = |
|
!!applyPatchConfig?.enabled && |
|
isOpenAIProvider(options?.modelProvider) && |
|
isApplyPatchAllowedForModel({ |
|
modelProvider: options?.modelProvider, |
|
modelId: options?.modelId, |
|
allowModels: applyPatchConfig?.allowModels, |
|
}); |
|
createOpenClawCodingTools(...) enables apply_patch for OpenAI models when tools.exec.applyPatch.enabled is set, making the vulnerable file-write surface reachable in normal embedded agent sessions. |
|
const applyPatchTool = |
|
!applyPatchEnabled || (sandboxRoot && !allowWorkspaceWrites) |
|
? // @ts-ignore -- cherry-pick upstream type mismatch |
|
null |
|
: createApplyPatchTool({ |
|
cwd: sandboxRoot ?? workspaceRoot, |
|
// @ts-ignore -- cherry-pick upstream type mismatch |
|
sandboxRoot: sandboxRoot && allowWorkspaceWrites ? sandboxRoot : undefined, |
|
}); |
|
The constructed applyPatchTool is added to the live tool list with cwd rooted to the workspace/sandbox root, so callers are led to trust the workspace-only boundary for this mutating tool. |
Advisory Details
Title: Workspace-only
apply_patchcan write outside the workspace through a dangling symlink leafDescription:
Summary
The experimental
apply_patchtool is documented as workspace-contained by default, but its path guard can be bypassed when the target path is a dangling symlink leaf located inside the workspace. In that case, the lexical path check succeeds, the symlink escape check returns early onENOENT, and the finalfs.writeFile(...)follows the symlink target outside the workspace root. A caller that can reach a normal embedded agent session withapply_patchenabled can therefore create or overwrite files outside the intended workspace boundary.Details
The affected boundary is the embedded coding-tool surface, not the public
/tools/invokeHTTP endpoint.createOpenClawCodingTools(...)conditionally exposesapply_patchwhentools.exec.applyPatch.enabledis true and the current model is an allowed OpenAI model. The tool is explicitly documented as workspace-contained by default.The defect sits in the interaction between
resolvePatchPath(...)andassertNoSymlinkEscape(...):resolvePatchPath(...)routes*** Add File:targets throughassertSandboxPath(...).assertSandboxPath(...)first enforces lexical root containment.assertNoSymlinkEscape(...)then walks path components withlstat().ENOENT, the function returns success immediately instead of resolving from the nearest existing ancestor.applyPatch(...)then performsfs.writeFile(...)on the symlink path, and the OS follows the dangling symlink target outside the workspace.The result is that a path like
jumpappears safe because it is lexically inside the workspace, while the actual write lands at the symlink target outside the workspace.Relevant code:
PoC
Prerequisites
tools.exec.applyPatch.enabledReproduction Steps
python3 llm-enhance/cve-finding/similar/workspace-boundary-bypass/Advisory-GHSA-qcc4-p59m-p54m-dangling-symlink-apply-patch-exp/verification_test.pyworkspace/jump -> /tmp/.../outside/owned.txt, invokes the realapply_patchtool with*** Add File: jump, and reports that the outside file now containspwned\n.python3 llm-enhance/cve-finding/similar/workspace-boundary-bypass/Advisory-GHSA-qcc4-p59m-p54m-dangling-symlink-apply-patch-exp/control-direct-traversal.py../escape.txttraversal is rejected withPath escapes sandbox root, confirming the bypass is specific to the dangling symlink alias rather than a total absence of path validation.Log of Evidence
Verification run:
Control run:
Impact
This is a filesystem boundary bypass on the embedded agent tool surface. Any deployment that enables
apply_patchand relies onworkspaceOnly=trueas a safety guarantee can be tricked into creating or overwriting files outside the intended workspace root, as long as the workspace contains a dangling symlink leaf chosen by the attacker-controlled patch path. The impact is not “public unauthenticated gateway takeover,” but it does break the advertised confinement guarantee for a mutating file tool. Depending on what exists outside the workspace, this can corrupt adjacent project files, host-side config, scripts, or other mounted content that operators reasonably believed was out of reach.Affected products
Severity
Weaknesses
Occurrences
openclaw-cn/src/agents/sandbox-paths.ts
Lines 101 to 139 in 558f272
assertNoSymlinkEscape(...)walks the candidate path, but returns success onisNotFoundPathError(err)without resolving a dangling final symlink leaf through the nearest existing ancestor.openclaw-cn/src/agents/apply-patch.ts
Lines 149 to 152 in 558f272
applyPatch(...)uses the validated target path fromresolvePatchPath(...)and immediately performsfs.writeFile(...), so any unresolved dangling symlink alias is followed by the OS at write time.openclaw-cn/src/agents/pi-tools.ts
Lines 237 to 245 in 558f272
createOpenClawCodingTools(...)enablesapply_patchfor OpenAI models whentools.exec.applyPatch.enabledis set, making the vulnerable file-write surface reachable in normal embedded agent sessions.openclaw-cn/src/agents/pi-tools.ts
Lines 309 to 317 in 558f272
applyPatchToolis added to the live tool list withcwdrooted to the workspace/sandbox root, so callers are led to trust the workspace-only boundary for this mutating tool.