The work-root guard I run before letting an agent near several repositories
When several repositories share one machine, an agent's working root is remarkably easy to mix up. Here is a small guard that compares resolved paths, tested against seven inputs.
Disk space was running low, so I wrote a small routine to clear out stale checkouts. I am working in this one repository, so keep it and remove the siblings. That was the reasoning behind this line:
for OTHER in /tmp/repos/*; do [ "$OTHER" = "$WORK" ] && continue rm -rf "$OTHER"done
As an indie developer maintaining iOS and Android apps alongside several site repositories, I usually have four or five checkouts sitting next to each other. This line was meant to protect exactly one of them.
It protected none. A single trailing slash on $WORK is enough for that comparison to never match.
Three ways a mix-up gets in
Working-root mix-ups do not announce themselves. They arrive through quiet mismatches: strings that differ while the underlying directory is the same, or the reverse. I walked into three of them.
Notation drift
/tmp/repos/siteA and /tmp/repos/siteA/ are different strings to the shell. Once two places in your code build that path, a trailing slash survives in one of them.
Symlink aliases
A convenience alias pointing at a checkout will never string-match the directory it points to.
Inherited depth
You may intend to hand the agent a repository root, but if the previous task descended into src/, that position quietly becomes the working root for the next one.
Watching it happen in a sandbox
Guessing at a fix usually shifts the problem somewhere else, so I reproduced the behaviour in a throwaway directory: siteA, siteB, and a symlink pointing at siteA.
T=$(mktemp -d); cd "$T"mkdir -p repos/siteA repos/siteBln -s "$T/repos/siteA" repos/siteA-linkWORK="$T/repos/siteA/" # trailing slashfor OTHER in "$T"/repos/*; do if [ "$OTHER" = "$WORK" ]; then echo " skip(self): $OTHER" else echo " DELETE: $OTHER"; fidone
The trailing slash is gone, the symlink is resolved, and both entry points to siteA land on the keep side. Resolve once before you compare — that is the whole idea.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to spot, on your own machine, the conditions under which an agent's working root gets mixed up with a sibling checkout
✦You will be able to add resolved-path matching to your cleanup routine before it deletes a checkout you meant to keep
✦You will be able to adapt a guard that returns the intended exit code for seven different inputs to your own repository layout
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Remembering to normalise is not a strategy that survives a busy week. I moved the checks into a single script that every agent task has to pass through first.
Check
What it prevents
Inside the allowed root
Escaping the allowed area via ../ or a symlink
Not the allowed root itself
Operations that sweep up every sibling repository
Matches the git toplevel
Mistaking a subdirectory for the repository root
#!/usr/bin/env bash# workroot-guard.sh — settle which directory an agent is allowed to work inset -euo pipefailALLOWED_ROOT="${ALLOWED_ROOT:?ALLOWED_ROOT is not set}"DECLARED_WORK="${1:?pass the working root as an argument}"[ -d "$DECLARED_WORK" ] || { echo "guard: no such working root: $DECLARED_WORK" >&2; exit 2; }ROOT_REAL=$(realpath -e "$ALLOWED_ROOT")WORK_REAL=$(realpath -e "$DECLARED_WORK")# (1) reject anything outside the allowed root, judged after .. and symlinks collapsecase "$WORK_REAL/" in "$ROOT_REAL"/*) : ;; *) echo "guard: outside the allowed root: $WORK_REAL (allowed: $ROOT_REAL)" >&2; exit 3 ;;esac# (2) never let the allowed root itself be the working root[ "$WORK_REAL" != "$ROOT_REAL" ] || { echo "guard: the allowed root cannot be the working root" >&2; exit 4; }# (3) ask the filesystem whether the declared path really is the git toplevelTOP=$(git -C "$WORK_REAL" rev-parse --show-toplevel 2>/dev/null || true)[ -n "$TOP" ] || { echo "guard: not a git repository: $WORK_REAL" >&2; exit 5; }[ "$(realpath -e "$TOP")" = "$WORK_REAL" ] || { echo "guard: not the toplevel. actual toplevel: $TOP" >&2; exit 6; }echo "$WORK_REAL"
On success it prints one line: the resolved path. Callers take it with WORK=$(workroot-guard.sh "$1") and touch nothing else afterwards. On failure the exit code separates the reasons, so the log tells you which check refused.
The -e in realpath -e means "error if the path does not exist". The working-root side is already refused by the -d check at the top, so where -e actually earns its place is a misspelled ALLOWED_ROOT.
Drop -e, mistype the root as rpos, and the script stops with exit 3 and guard: outside the allowed root: .../siteA (allowed: .../rpos), a message that points away from the real cause. With -e you get realpath: .../rpos: No such file or directory, the misspelled name itself (the exit code is 1). Both runs stop at the same point, but only one of them leaves a log line that sends you to the right place.
Seven inputs, seven expected outcomes
Once written, run it. I lined up the inputs I expected to see and checked that each stops in the intended place.
export ALLOWED_ROOT="$T/repos"run(){ printf '%-38s -> ' "$1"; out=$(./workroot-guard.sh "$2" 2>&1) \ && echo "OK $out" || echo "exit=$? $out"; }run "repository toplevel" "$T/repos/siteA"run "with a trailing slash" "$T/repos/siteA/"run "through a symlink alias" "$T/repos/siteA-link"run "a subdirectory inside it" "$T/repos/siteA/src"run "the allowed root itself" "$T/repos"run "escaping with ../" "$T/repos/../repos/../"run "plain dir inside the root" "$T/repos/plain"
Results:
Input
Outcome
What it returned
repository toplevel
OK
.../repos/siteA
with a trailing slash
OK
.../repos/siteA (slash dropped)
through a symlink alias
OK
.../repos/siteA (resolved)
a subdirectory inside it
exit 6
not the toplevel. actual toplevel: .../siteA
the allowed root itself
exit 4
the allowed root cannot be the working root
escaping with ../
exit 3
outside the allowed root
plain dir inside the root
exit 5
not a git repository
The first three rows converging on the same .../repos/siteA is the guard doing its job. Three textually different inputs become one value for everything downstream.
In my first version the last row was $T/bin, a directory outside the allowed root. It came back exit 3, which looked like the guard stopping where it should. Lining the exit codes up check by check showed that the exit 5 in check (3) had never fired once. Check (1) had been doing the stopping all along. Once I put a non-git plain directory inside repos and swapped it in, exit 5 finally appeared.
A table of exit codes only proves the checks work when each check produces its own value.
I also tried one input that is not in the table: repos-evil, a sibling whose name starts with the same letters as the allowed root repos. It returns exit 3. The case in check (1) compares "$WORK_REAL/", with a trailing slash, to avoid exactly this. Drop the / from the pattern and write "$ROOT_REAL"*, and repos-evil lands on the allowed side. I pulled the case out on its own to confirm: without the slash it matched wrongly, with the slash it refused.
Asking "is this a git repository?" is not enough
Row four was the one that surprised me.
git -C .../siteA/src rev-parse --show-toplevelsucceeds. It does not error. It happily returns .../siteA, the correct toplevel. So to the question "is the path you were handed a git repository?", a subdirectory answers yes with complete confidence.
I had been treating that call as a test for "is this the repository root". It is only a test for "is this somewhere inside a repository". To confirm the toplevel, you have to take the answer and compare it against the declared path yourself. That is why check (3) in the script spans two lines instead of one.
The distinction matters most when an agent inherits its position from a previous task. Standing in src/ and being told "work in this repository" passes cleanly, because it genuinely is inside the repository. Then every relative output path sprouts one level deeper than you expected.
Hand the same resolved path to the cleanup
The value the guard returns is for tidying up as well as for working. Here is the opening cleanup loop, rewritten to compare resolved paths. This is the first version I wrote.
ROOT_REAL=$(realpath "$ALLOWED_ROOT")WORK_REAL=$(./workroot-guard.sh "$DECLARED_WORK") # stops here, and the sweep never runsfind "$ROOT_REAL" -mindepth 1 -maxdepth 1 -print0 | while IFS= read -r -d '' P; do PR=$(realpath -e "$P") || continue if [ "$PR" = "$WORK_REAL" ]; then echo " keep : $P"; continue; fi echo " sweep: $P -> $PR" rm -rf -- "$PR"done
I ran it with siteA, siteB, siteC and a symlink siteA-link, declaring the working root through the symlink, with a marker file inside siteA. siteA and siteA-link stayed; siteB and siteC went. So far that is what I wanted, and both the marker file and the repos directory itself survived.
Then I tried one more thing: an old symlink pointing at a directory outside the allowed root. I put a keep.txt in outside/data, pointed repos/old-link at it, and ran the same steps again.
Afterwards I looked for outside/data/keep.txt. It was gone.
rm -rf -- "$PR" removes the link's destination, not the link. I had meant to clean inside the allowed root and instead deleted real data outside it. The cause is that I reused the resolved value, computed for comparison, as the thing to delete. The realpath I added to defeat notation drift had started widening what got deleted. Looking back, sharing one variable between "compare" and "delete" was the moment I could have caught it.
Compare by the real path; delete by the entry. Use the realpath result for the comparison, and hand rm the name find gave you. If that entry is a link, rm -- removes the link alone and never touches the destination.
find "$ROOT_REAL" -mindepth 1 -maxdepth 1 -print0 | while IFS= read -r -d '' P; do PR=$(realpath "$P" 2>/dev/null || true) # keep going even if it will not resolve if [ -n "$PR" ] && [ "$PR" = "$WORK_REAL" ]; then echo " keep : $P"; continue; fi if [ -L "$P" ]; then echo " unlink: $P"; rm -- "$P"; continue; fi # never touch the destination echo " sweep : $P"; rm -rf -- "$P"done
Same conditions, plus a dangling link dangling and a non-git plain directory (sorted here for readability):
outside/data/keep.txt survived, and what remained in repos was siteA and siteA-link. A dangling link never matches the comparison and just has the link removed by the -L branch.
The -- in rm -rf -- "$P" stops a directory whose name begins with a hyphen from being read as an option. Nobody creates such names by hand, but build output manages it.
Forget -mindepth 1 and the parent goes too
If your cleanup uses find, -mindepth 1 is not decoration. find includes its own starting point in the results, so leaving it out puts the parent directory on the list.
# starting point includedfind "$T/cache" -maxdepth 1 -type d -exec rm -rf {} +# → does cache still exist: NO# with -mindepth 1find "$T2/cache" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} +# → does cache still exist: yes# → does keep.txt still exist: yes
The first form took cache with it. I only wanted a/ and b/ gone, but keep.txt sitting beside them disappeared as well. The second form left cache and keep.txt in place and removed only the directories.
While you are using -delete, non-empty directories fail loudly and you notice. The moment you switch to -exec rm -rf {} +, it goes through in silence. Check for -mindepth 1 precisely when you change that form.
What set -u covers, and what it does not
Of the set -euo pipefail at the top of the script, -u is the one carrying weight here. Expanding an undefined variable stops the script on the spot.
Without set -u, "${UNSET_VAR2}/build" becomes the string /build. A typo in a variable name is enough to aim a deletion at the root of the filesystem.
But -u catches undefined only. An empty string passes straight through: WORK="" is defined, so it silently becomes /build. When empty must be refused too, use the ${WORK:?...} form. That is exactly why the script opens with ALLOWED_ROOT="${ALLOWED_ROOT:?...}".
On macOS, be suspicious of realpath -e
Everything above was run on Linux (bash 5.1, GNU coreutils 8.32). I would like to use it on my iMac as well, but -e is a GNU extension and the realpath that ships with macOS may reject it. I have not run this on a real Mac, so treat that part as unverified.
So I wrote a version that does not lean on -e. It finds the real path with cd -P and pwd -P, and I compared it against the original on Linux. Only the helper and the three places that call it change.
# macOS's stock realpath may lack GNU's -e, so resolve with cd -P / pwd -Preal() { ( cd -P -- "$1" && pwd -P ); }ROOT_REAL=$(real "$ALLOWED_ROOT")WORK_REAL=$(real "$DECLARED_WORK")# ... (1) and (2) unchanged ...[ "$(real "$TOP")" = "$WORK_REAL" ] || { echo "guard: not the toplevel. actual toplevel: $TOP" >&2; exit 6; }
I used ten inputs: the seven from the table, plus the look-alike sibling, a path that does not exist, and a regular file. Exit codes and messages matched the original every time. The only difference showed up with a mistyped ALLOWED_ROOT, where you get cd: ...: No such file or directory instead of realpath: .... Either way the misspelled name is visible.
The helper only handles directories. A file makes it fail, but a working root is always a directory, and the -d check at the top stops it first.
Wiring it into agent instructions
With the guard in place, the remaining work is shaping things so the agent never picks a path itself. Three habits carried most of the benefit for me.
Settle the working root once, at the start of the task, and leave it alone. Rather than telling the agent to cd somewhere inside the prompt, pass the absolute path the guard returned and spell it out each time as git -C "$WORK" .... The less your commands depend on the current directory, the less a drifting position changes the outcome.
Write anything that deletes only downstream of a successful guard. If the guard exits 3, not one line of the sweep runs. With set -e in place you get that without writing an explicit if.
And keep the guard's output in the log. A single line reading guard: not the toplevel. actual toplevel: ... tells you later where you thought you were working. Agent logs grow long, but that one line earns a findable spot.
Start by checking whether realpath appears anywhere in the cleanup lines you already run. If it does not, adding the two lines that resolve both sides before comparing is enough to begin.
Until I put this guard in place, I assumed I simply would not get the working directory wrong. People do not get it wrong. Notation drift in a path just never looks like a mistake.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.