zeroclaw-labs/zeroclaw · error · anyhow::Error
Cannot write screenshot to symlink target '{ $target }'
Error message
Cannot write screenshot to symlink target '{ $target }' What it means
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.
Source
Thrown at crates/zeroclaw-tools/src/browser.rs:924
let msg = crate::i18n::get_required_tool_string_with_args(
"tool-browser-screenshot-error-runtime-config-target",
&[
("path", raw_path),
("target", &resolved_target.display().to_string()),
],
);
anyhow::bail!("{msg}");
}
// If the target already exists and is a symlink, refuse to follow it.
if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await
&& meta.file_type().is_symlink()
{
let msg = crate::i18n::get_required_tool_string_with_args(
"tool-browser-screenshot-error-symlink-target",
&[("target", &resolved_target.display().to_string())],
);
anyhow::bail!("{msg}");
}
// The allowlist above validated the byte-preserving PathBuf. Every
// backend receives the destination as a UTF-8 string, and a lossy
// conversion (`to_string_lossy`) would silently replace non-UTF-8
// bytes with U+FFFD — naming a pathname that never passed the policy.
// Fail closed here, while we still hold the checked target: on Unix a
// valid UTF-8 input can canonicalize (through a symlink) to a parent
// containing non-UTF-8 bytes.
let Some(resolved_str) = resolved_target.to_str() else {
let msg = crate::i18n::get_required_tool_string_with_args(
"tool-browser-screenshot-error-path-not-utf8",
&[("path", raw_path)],
);
anyhow::bail!("{msg}");
};
Ok(resolved_str.to_string())View on GitHub (pinned to 88bb9c8533)
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
Example fix
// before (shot.png is a symlink to /shared/shot.png)
{"action": "screenshot", "path": "shot.png"}
// after (write directly into a real directory)
{"action": "screenshot", "path": "shared/shot.png"} Defensive patterns
Strategy: validation
Validate before calling
let meta = tokio::fs::symlink_metadata(&target).await?;
if meta.file_type().is_symlink() {
// refuse before calling the tool; pick a fresh destination filename
} Try / catch
match tool.execute(args).await {
Ok(res) => { /* ... */ }
Err(e) if e.to_string().contains("symlink target") => {
// delete the symlink or choose another filename, then retry
}
Err(e) => return Err(e),
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- Cannot write screenshot to runtime config path '{ $target }'
- Screenshot path '{ $path }' resolves to a non-UTF-8 pathname
- attachment path {} canonicalizes to {} which escapes workspa
- Key file path is a symlink — refusing to write
- Refusing to copy symlinked skill source path: {}
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/a9c7b8f4e7127c74.
Report an issue: GitHub.