---
id: 7a84c65c-d72c-46e0-ab96-2c43bdeb7b0d
title: 'build: -o guard bypass via .. / symlink deletes parent or repo (data loss)'
state: closed
labels:
- kind:security
assignees: []
milestone: null
created: 2026-10-27T17:24:52.294202432Z
updated: 2026-10-27T17:45:35.161333041Z
---
## What

`prepare_out` protects the repo with a **lexical** path check:

- `src/build.rs:1144` — `absolute(out)` only joins cwd (`.`/`..` are left in).
- guard: `out_abs == repo_abs || repo_abs.starts_with(&out_abs)`.

`Path::starts_with` normalizes `.` but **not `..`**, so `<repo>/..` passes the guard and `remove_dir_all` resolves through `..` and deletes the parent directory.

## Reproduced

```
mkdir -p /tmp/prep/repo && cd /tmp/prep/repo && git init -b main
echo keep > README.md && git add -A && git commit -m one
echo CANARY > /tmp/prep/keep.txt
hu13 build . -o ..
```

Observed: `/tmp/prep/keep.txt`, the repo's `.git` and all sources were deleted; `repo/` was recreated empty and the build failed with `Unable to read current working directory`. A symlinked ancestor (`-o /tmp/alias/src` where `alias -> repo`) deletes repo contents the same way.

## Impact

Data loss: a plausible `-o ..` typo or a bad build command wipes the checkout and its parent. The #67 guard explicitly claims to refuse ancestors.

## Fix

- Lexically normalize `out` (resolve `.`/`..`) and canonicalize the deepest existing ancestor before any comparison.
- Compare resolved paths; refuse when resolved `out` equals the repo or contains it.
- Also refuse an in-repo `out` that contains tracked files (`src/`, `.forge/`) while keeping `dist/` legal; or require it to be empty/gitignored.
- Tests: absolute parent, relative `..`, `../..`, symlinked ancestor; assert a canary outside the output survives.
