Hmbown/CodeWhale · error · Error

Unsafe update path.

Error message

Unsafe update path.

What it means

Every central-directory entry name must be safe to extract into the app bundle: it must live under the `<APP_NAME>.app/` prefix, contain no backslashes, colons or NUL bytes, have no `.` or `..` path segments, and not duplicate an already-seen name. This is path-traversal and aliasing defense: without it a zip entry like `../../LaunchAgents/evil` could write outside the bundle, or a duplicate name could overwrite an earlier file. The validator throws before any bytes are written to disk.

Solutions

  1. Repackage so the archive root is exactly `Codewhale-Computer-Use.app/`: run `ditto -c -k --keepParent MyApp.app app.zip` from the directory containing the bundle.
  2. List the archive (`unzip -l`) and remove entries with `..`, `.`, backslashes, colons, or duplicate names from the packaging step.
  3. Do not append to an existing zip; build a fresh archive each release so names cannot repeat.
  4. If entries intentionally start with a different name, rename the bundle to match APP_NAME — the prefix is not configurable.

Example fix

// before
cd MyApp.app && zip -r ../app.zip .  // entries lack 'MyApp.app/' prefix
// after
ditto -c -k --keepParent MyApp.app app.zip
Defensive patterns

Strategy: validation

Validate before calling

const names = execFileSync("unzip", ["-Z1", zipPath]).toString().split("\n").filter(Boolean);
const ok = names.every(n => n.startsWith(`${APP_NAME}.app/`) && !/[\\:\0]/.test(n) && !n.split("/").some(p => p===".."||p==="."));

Try / catch

try { validateReleaseZip(bytes); } catch (e) { if (e.message === "Unsafe update path.") throw new Error("Zip entry escapes the bundle — repackage so the root is <APP_NAME>.app/"); throw e; }

Prevention

When it happens

Trigger: A zip whose entries don't all start with `Codewhale-Computer-Use.app/` (e.g. zipping the folder contents instead of the folder), entries containing `..` segments, absolute-path or Windows-style separators in names, or two entries with the exact same name.

Common situations: Running `zip -r app.zip .` from inside the bundle directory so entries lack the `.app/` prefix; a build script embedding `./` segments; zips created on Windows with backslash separators; malicious zips in a path-traversal security test; duplicate entries produced by appending to an existing zip twice.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/c59d142e5bf3a4c0. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/plugins/computer-use/app/updates.mjs:64

  return releaseUpdate(JSON.parse((await responseBytes(response,1024*1024)).toString("utf8")));
}

/** Inspect both ZIP headers before extraction: no links, traversal or bombs. */
export function validateReleaseZip(bytes) {
  const minimum=Math.max(0,bytes.length-65557); let end=-1;
  for(let i=bytes.length-22;i>=minimum;i--) if(bytes.readUInt32LE(i)===0x06054b50&&i+22+bytes.readUInt16LE(i+20)===bytes.length) { end=i; break; }
  if(end<0||bytes.readUInt16LE(end+4)||bytes.readUInt16LE(end+6)) throw new Error("Invalid update archive.");
  const count=bytes.readUInt16LE(end+10); let position=bytes.readUInt32LE(end+16),total=0;
  if(!count||count>2000||bytes.readUInt16LE(end+8)!==count||position+bytes.readUInt32LE(end+12)!==end) throw new Error("Invalid update archive index.");
  const seen=new Set();
  for(let i=0;i<count;i++) {
    if(position+46>end||bytes.readUInt32LE(position)!==0x02014b50) throw new Error("Invalid update entry.");
    const flags=bytes.readUInt16LE(position+8),method=bytes.readUInt16LE(position+10),length=bytes.readUInt16LE(position+28),extra=bytes.readUInt16LE(position+30),comment=bytes.readUInt16LE(position+32);
    const name=bytes.subarray(position+46,position+46+length).toString("utf8");
    const kind=(bytes.readUInt32LE(position+38)>>>16)&0xf000,offset=bytes.readUInt32LE(position+42),compressed=bytes.readUInt32LE(position+20);
    const size=bytes.readUInt32LE(position+24); total+=size;
    if(flags&1||![0,8].includes(method)||![0,0x4000,0x8000].includes(kind)||total>512*1024*1024||position+46+length+extra+comment>end) throw new Error("Unsupported update entry.");
    if(!name.startsWith(`${APP_NAME}.app/`)||name.includes("\\")||name.includes(":")||name.includes("\0")||name.split("/").some(part=>part===".."||part===".")||seen.has(name)) throw new Error("Unsafe update path.");
    seen.add(name);
    if(offset+30>position||bytes.readUInt32LE(offset)!==0x04034b50) throw new Error("Invalid update file header.");
    const localLength=bytes.readUInt16LE(offset+26),localExtra=bytes.readUInt16LE(offset+28);
    if(offset+30+localLength+localExtra+compressed>bytes.readUInt32LE(end+16)||bytes.subarray(offset+30,offset+30+localLength).toString("utf8")!==name) throw new Error("Inconsistent update file header.");
    if(bytes.readUInt16LE(offset+8)!==method||bytes.readUInt16LE(offset+6)!==flags||(!(flags&8)&&(bytes.readUInt32LE(offset+18)!==compressed||bytes.readUInt32LE(offset+22)!==size))) throw new Error("Inconsistent update sizes or compression.");
    const start=offset+30+localLength+localExtra;
    // Header sizes are untrusted. Bound actual expansion before ditto writes
    // anything, including a compressed payload whose headers understate size.
    const payload=bytes.subarray(start,start+compressed);
    let expanded;
    try { expanded=method===0?payload.length:inflateRawSync(payload,{maxOutputLength:Math.max(size,1)}).length; }
    catch { throw new Error("Invalid or oversized compressed update entry."); }
    if(expanded!==size) throw new Error("The update entry size did not match its contents.");
    position+=46+length+extra+comment;
  }
  if(position!==end) throw new Error("Invalid update archive length.");
  return count;
}

View on GitHub (pinned to 73e0f67d83)