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

  1. Remove the symlink (rm shot.png) or point the screenshot at a fresh, non-symlink filename
  2. If output redirection is needed, configure the output directory or workspace instead of symlinking the destination file
  3. 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

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


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/a9c7b8f4e7127c74. Report an issue: GitHub.