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". Without it, a misspelled path that points nowhere gets normalised and sails through. Do not drop it.
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 "not under git at all" "$T/bin"
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
not under git at all
exit 3
outside the allowed root
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.
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 against resolved paths:
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" rm -rf -- "$PR"done
Running it with siteA, siteB, siteC and a symlink present, declaring the working root through the symlink:
The marker file inside siteA survived, and the repos directory itself survived. Declaring through an alias still protected the right thing.
The -- in rm -rf -- "$PR" 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:?...}".
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.