{"record":{"id":"a9c7b8f4e7127c74","repo":"zeroclaw-labs/zeroclaw","slug":"cannot-write-screenshot-to-symlink-target-targ","errorCode":null,"errorMessage":"Cannot write screenshot to symlink target '{ $target }'","messagePattern":"Cannot write screenshot to symlink target '(.+?)'","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-tools/src/browser.rs","lineNumber":924,"sourceCode":"            let msg = crate::i18n::get_required_tool_string_with_args(\n                \"tool-browser-screenshot-error-runtime-config-target\",\n                &[\n                    (\"path\", raw_path),\n                    (\"target\", &resolved_target.display().to_string()),\n                ],\n            );\n            anyhow::bail!(\"{msg}\");\n        }\n\n        // If the target already exists and is a symlink, refuse to follow it.\n        if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await\n            && meta.file_type().is_symlink()\n        {\n            let msg = crate::i18n::get_required_tool_string_with_args(\n                \"tool-browser-screenshot-error-symlink-target\",\n                &[(\"target\", &resolved_target.display().to_string())],\n            );\n            anyhow::bail!(\"{msg}\");\n        }\n\n        // The allowlist above validated the byte-preserving PathBuf. Every\n        // backend receives the destination as a UTF-8 string, and a lossy\n        // conversion (`to_string_lossy`) would silently replace non-UTF-8\n        // bytes with U+FFFD — naming a pathname that never passed the policy.\n        // Fail closed here, while we still hold the checked target: on Unix a\n        // valid UTF-8 input can canonicalize (through a symlink) to a parent\n        // containing non-UTF-8 bytes.\n        let Some(resolved_str) = resolved_target.to_str() else {\n            let msg = crate::i18n::get_required_tool_string_with_args(\n                \"tool-browser-screenshot-error-path-not-utf8\",\n                &[(\"path\", raw_path)],\n            );\n            anyhow::bail!(\"{msg}\");\n        };\n\n        Ok(resolved_str.to_string())","sourceCodeStart":906,"sourceCodeEnd":942,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-tools/src/browser.rs#L906-L942","documentation":"After the screenshot destination is resolved, the validator stats the target with symlink_metadata; if the file already exists and is a symlink, the write is refused. The workspace allowlist validated the link's own path, not whatever the link points at, so following it could redirect PNG bytes anywhere on disk. ZeroClaw fails closed instead of following the link.","triggerScenarios":"A pre-existing symlink at the requested destination: `ln -s /etc/cron.d/pwn shot.png` inside the workspace, then a screenshot action with path=\"shot.png\". Also symlinked output files left by dotfile managers or sync tools.","commonSituations":"Users 'redirect' screenshot output to a shared folder by symlinking the destination file; CI containers mount symlinked artifacts into the workspace; adversarial agents chain a symlink to escape the workspace allowlist.","solutions":["Remove the symlink (rm shot.png) or point the screenshot at a fresh, non-symlink filename","If output redirection is needed, configure the output directory or workspace instead of symlinking the destination file","Verify with `ls -l <target>` or symlink_metadata before retrying"],"exampleFix":"// before (shot.png is a symlink to /shared/shot.png)\n{\"action\": \"screenshot\", \"path\": \"shot.png\"}\n// after (write directly into a real directory)\n{\"action\": \"screenshot\", \"path\": \"shared/shot.png\"}","handlingStrategy":"validation","validationCode":"let meta = tokio::fs::symlink_metadata(&target).await?;\nif meta.file_type().is_symlink() {\n    // refuse before calling the tool; pick a fresh destination filename\n}","typeGuard":null,"tryCatchPattern":"match tool.execute(args).await {\n    Ok(res) => { /* ... */ }\n    Err(e) if e.to_string().contains(\"symlink target\") => {\n        // delete the symlink or choose another filename, then retry\n    }\n    Err(e) => return Err(e),\n}","preventionTips":["Audit the workspace for pre-existing symlinks before screenshot-heavy sessions","Create screenshot output directories at session start instead of pre-creating destination files","Never use symlinks to redirect tool output — configure the output directory instead"],"tags":["browser","screenshot","symlink","security","path-validation"],"backgroundTag":"symlink-escape","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}