May 30, 2026
AI1 code assistants are becoming ubiquitous these days2, and it’s not different on my daily job. Indeed, I plan to play with it in my Zig and Zephyr series. But the idea of letting the statistics based machine to mess with my filesystem is a bit scary, so I ended up with a plan to mitigate risks: enter ainvoke.
My initial idea was to run the code assistant inside a container - Docker, as I’m a bit more familiar with. That would contain it - see what I did? - but I still need it to access the files needed to complete its task.
It is easy enough to mount a directory from the host to make it accessible to the container, but I don’t want it touching the same files anyway. It needs to be a safer copy. First I thought of literally copying the files from current directory to some /tmp one and make the copy accessible to the container. But after writing my scripts to do that, it became clear that copying entire directories would take forever. And waste a lot of space. In a normal day of work, I’ll be dealing with Zephyr OS, and its workspace in my machine has more than 196k files3 using around 14G4. I could be more selective of which modules to copy, but then this is getting quite annoying.
Then I’ve learned about the overlay filesystem. It allows one to create union mounts and one of its use cases is to create a read/write partition on top of a read-only one (like a CD) - and that’s precisely what I wanted: keep my “real” directory intact while letting the AI code assistant mess with a copy of it.
The problem of getting the changes done by the assistant back to my real directory would be solved by git anyways: just commit and push to a remote in some forge, and bring it from the forge to the real one. Like syncing two development machines. Note that the forge (or just another directory) middle step is important: I want to reduce the risk of the assistant doing force pushes and destroying stuff on my real directory.
I’ve iterated (and am still iterating) on this work, and came up with three main scripts: one to build the container, one Dockerfile and one to run the container.
To build the container, nothing very special:
#!/bin/bash
set -ex
SOURCE_ENV=variables.sh
if ! which docker &> /dev/null; then
echo Need docker to run
exit 1
fi
if ! groups | grep -wq docker; then
echo "Add user to group 'docker'"
exit 1
fi
if [ ! -f $SOURCE_ENV ]; then
echo "No 'variables.sh' found"
exit 1
fi
source $SOURCE_ENV
docker build --build-arg USER=$USER --build-arg USER_ID=$USER_ID \
--build-arg GROUP=$GROUP --build-arg GROUP_ID=$GROUP_ID \
--build-arg CLAUDE_CODE_OAUTH_TOKEN=$CLAUDE_CODE_OAUTH_TOKEN \
--build-arg COPILOT_GITHUB_TOKEN=$COPILOT_GITHUB_TOKEN \
-t ainvoke --target ainvoke .
First, some sanity checks. Then it sources a variables.sh bash file. What’s in there? Some information to use on the build of the container. For instance:
# By default, use current user info for [USER|GROUP][_ID]
USER=$(id -nu)
USER_ID=$(id -u)
GROUP=$(id -ng)
GROUP_ID=$(id -g)
CLAUDE_CODE_OAUTH_TOKEN=<claude-code-token>
COPILOT_GITHUB_TOKEN=<github-copilot-token>
It gathers current user and group information to allow the container to run with user privileges. This allows changes done by the assistant to the file system to have a normal user as owner, and not root. That way, if the agent creates a new file foo, it will have my own user as owner instead of root. Having the files belong to root would be very annoying, as I couldn’t touch them without lots of chown and chmod. The token variables are self evident.
When the container is built, these informations are sent to the Dockerfile as --build-arg arguments.
The Dockerfile just uses Arch Linux as base (it is what I use day-to-day, so more familiar with) and install some utilities on top - like git, screen, vim. It also sets up what is needed to run a Zephyr “development workspace”. Naturally, it also installs Zig, but for now, I’m sticking with version 0.15.
Finally, it installs Github Copilot and Claude Code:
FROM archlinux:multilib-devel AS ainvoke
ARG USER
ARG USER_ID
ARG GROUP
ARG GROUP_ID
ARG CLAUDE_CODE_OAUTH_TOKEN
ARG COPILOT_GITHUB_TOKEN
RUN pacman -Syu --noconfirm && \
pacman -S --noconfirm git vim screen python-pip cmake wget ninja \
dtc tig less bash-completion
RUN python -m venv /venv && \
source /venv/bin/activate && \
pip install --upgrade pip && \
pip install west
RUN groupadd -g ${GROUP_ID} ${GROUP} && \
useradd -rm -d /home/${USER} -s /bin/bash -g ${GROUP} -G wheel -u ${USER_ID} ${USER}
RUN echo "${USER} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
USER ${USER}
RUN mkdir ~/yay && \
pushd ~/yay && \
wget -O PKGBUILD https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h=yay && \
makepkg --noconfirm -si && \
popd
RUN yay --noconfirm -S zig0.15-bin
RUN curl -fsSL https://claude.ai/install.sh | bash
RUN curl -fsSL https://gh.io/copilot-install | bash
# Work around a Claude Code bug where CLAUDE_CODE_OAUTH_TOKEN is ignored
# unless onboarding is marked complete in ~/.claude.json.
RUN python -c "import json, os; p = os.path.expanduser('~/.claude.json'); d = json.load(open(p)) if os.path.exists(p) else {}; d['hasCompletedOnboarding'] = True; json.dump(d, open(p, 'w'))"
ENV PATH=${PATH}:/home/${USER}/.local/bin
ENV CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN}
ENV COPILOT_GITHUB_TOKEN=${COPILOT_GITHUB_TOKEN}
Note the “Claude Code” bug workaround: it was written by Claude Code! Recursion! I’d probably just use sed, but hey, this seem safer! And docker will complain about the token variables. As I’m expecting all this to be run locally, it should be OK.
The final script does the AI invocation! It takes a directory as argument, and create not one, but two overlays on top of it! Why? One is for the AI to use, the other one is for extra play. You can not touch5 the underlying directory while the overlays are mounted, so having an extra “play” directory allows that.
After setting them up, the script prints their location. They live at /tmp, the one for the container starts with ai_merged_ and the play one with ai_play_. We can access assistant changes via this directory, for instance, I usually don’t want the assistant to flash devices, so I can run west flash from the ai_merged_ directory, using the host environment.
After the docker section ends, the script unmounts everything, leaving some messages where to find the “upper” directory for the mounts: they contain the changes on top of the underlying directory, and are there in case something needs to be rescued. The script:
#!/bin/bash
# Ensure first arg is a directory
if [ -z "$1" ]; then
echo "Usage: $0 <directory>"
exit 1
fi
DIR="$1"
UPPER_DIR=$(mktemp -d --tmpdir ai_upper_XXXXXX)
WORK_DIR=$(mktemp -d --tmpdir ai_work_XXXXXX)
MERGED_DIR=$(mktemp -d --tmpdir ai_merged_XXXXXX)
PLAY_UPPER_DIR=$(mktemp -d --tmpdir ai_play_upper_XXXXXX)
PLAY_WORK_DIR=$(mktemp -d --tmpdir ai_play_work_XXXXXX)
PLAY_MERGED_DIR=$(mktemp -d --tmpdir ai_play_merged_XXXXXX)
echo "Merged dir lives at '$MERGED_DIR'"
echo "Play dir (so taht you don't touch lower dir) lives at '$PLAY_MERGED_DIR'"
sudo mount -t overlay overlay -o lowerdir="$DIR",upperdir="$UPPER_DIR",workdir="$WORK_DIR" "$MERGED_DIR"
sudo mount -t overlay overlay -o lowerdir="$DIR",upperdir="$PLAY_UPPER_DIR",workdir="$PLAY_WORK_DIR" "$PLAY_MERGED_DIR"
docker run --rm -it --mount type=bind,src="$MERGED_DIR",dst="$MERGED_DIR" \
ainvoke:latest bash -c "cd $MERGED_DIR && copilot update ; claude update ; bash"
echo "Last call to check relevant files at '$MERGED_DIR'"
echo "Press enter to unmount"
read -r
sudo umount "$MERGED_DIR"
sudo umount "$PLAY_MERGED_DIR"
echo "Upper is at '$UPPER_DIR', in case you still want to check it out. You can delete it when done."
echo "Play upper is at '$PLAY_UPPER_DIR', in case you still want to check it out. You can delete it when done."
With everything in place and the script on PATH, when it’s time to invoke AI, I simply do:
$ ainvoke.sh .
Or, when dealing with Zephyr, I usually end up doing:
$ ainvoke.sh ..
As I probably need the whole Zephyr workspace, to have modules available.
Then I get:
Merged dir lives at '/tmp/ai_merged_mjX9en'
Play dir (so taht you don't touch lower dir) lives at '/tmp/ai_play_merged_iFRPlM'
[sudo] password for ederson:
Checking for updates...
Checking GitHub for the latest release...
Update available: v1.0.56 (current: 1.0.54).
Downloading update package...
Download complete. Installing...
Copilot CLI version 1.0.56 installed.
Current version: 2.1.152
Checking for updates to latest version...
Successfully updated from 2.1.152 to version 2.1.158
[ederson@00b1e1b1f9bb ai_merged_mjX9en]$
It shows where the “merged” (the one shared with the container) and “play” (the one to play without touching the underlying one) directories live. The sudo call is to do the mounting part. As I made the script also update both assistants before, those are the following lines. Those things are updated very frequently, so I think it makes sense to ensure they are up to date after every invocation. Finally, I get the prompt from inside the container. Running ls I get:
[ederson@00b1e1b1f9bb ai_merged_mjX9en]$ ls
bootloader build edk edk-old modules sdk tools zephyr
[ederson@00b1e1b1f9bb ai_merged_mjX9en]$
The Zephyr workspace is listed. Time to invoke either copilot or claude and... vibe away (sorry).
When done, just exit the container with exit or Ctrl+D:
[ederson@00b1e1b1f9bb ai_merged_mjX9en]$ exit
exit
Last call to check relevant files at '/tmp/ai_merged_mjX9en'
Press enter to unmount
Upper is at '/tmp/ai_upper_Ey3BXS', in case you still want to check it out. You can delete it when done.
Play upper is at '/tmp/ai_play_upper_Ydm1uZ', in case you still want to check it out. You can delete it when done.
Yes! Check it out at my Github.
Yes, they are better described as LLMs (or BS Generators). But AI is how they are known, so that’s the term I’ll use. ↩
At least with current pricing model. But I expect that hikes to prices will only make people look for alternatives, like local open models, not forego them. ↩
As estimated via find . -type f | wc -l ↩
As estimated via du -hs . ↩
Well, technically you can, but you shouldn’t. Chaos will plague the overlay mounts if you do. ↩