I want my coding agents to run unattended. But I have to admit that I have trust issues also.
And this is why the initial question for this article came up: could I containerize and secure Bob Shell 2.x well enough to let it work without me watching every tool call? I was curious, so I tried it with two real tasks. I wanted both agents to work at the same time, but I did not want them editing my main checkout or reading the rest of my laptop or do something even worse.
Running two agents in one checkout is also a very good learning experience about how much state Maven and Git quietly share. One agent changes a resource while the other recompiles it. Both write to target/. A cleanup command can absolutely affect the wrong branch and would force me to spend the time I saved on parallel work reviewing a mixed diff and repairing the build.
Git worktrees give each task its own branch, source tree, and build directory. Podman lets me decide which host files enter the container with deliberate file mounts and with Bob Shell I get a non-interactive runner, cost limits, and machine-readable output. That looked like the right combination to fight my trust issues.
Getting that combination to work though exposed a few constraints. Bob Shell 2.x uses bob run and BOB_API_KEY, and its non-interactive mode approves tool calls automatically. Also linked worktrees gave me some headaches when I mounted the repository at a different path inside the container. I also had to remember that concurrent Quarkus tests need separate ports, and two fresh Maven wrappers should not unpack the same distribution into one shared cache at the same time. All this is what I am going to cover in the article. So buckle up, it is going to be a wild ride.
I tested the complete flow with Bob Shell 2.0.2, Quarkus 3.39.1, OpenJDK 21, Maven 3.9.16 through Maven Wrapper, Node.js 24, and Podman. IBM’s Bob release page may list newer 2.x releases when you read this. It should still work (hopefully). You can most likely do something similar with other agents too. I just picked Bob, because I have access and it has an easy trial available so you can give it a crack too. Just register if you like: https://bob.ibm.com/trial.
What We Are Building
The lab uses a small Quarkus application with two independent REST endpoints:
GET /catalogreturns three catalog itemsGET /shipping/quotereturns a standard shipping quote
We create two worktrees from the same clean commit. Bob adds optional catalog search in one and express shipping in the other. I run both agents at the same time inside one long-lived Podman container.
The repository contains the complete starting point that you will check out later:
parallel-bob-lab/
├── Containerfile
├── pom.xml
├── prompts/
│ ├── catalog-search.md
│ └── express-shipping.md
├── scripts/
│ ├── container-unzip.sh
│ ├── run-bob.sh
│ ├── start-sandbox.sh
│ ├── stop-sandbox.sh
│ └── verify-worktrees.sh
└── src/
├── main/java/com/mainthread/
│ ├── catalog/
│ └── shipping/
└── test/java/com/mainthread/
├── catalog/
└── shipping/The tasks sit in different packages. Worktrees isolate checkout state; they do not make overlapping changes easy or automatically possible. So I made sure that the tasks we want to work on are independent before I ask the agents to run in parallel. This is a general recommendation. Even with parallel agents, you can burn a lot of token if you let them work “against each other” fighting over changes and merges.
What You Need
Install these tools on your machine or the host you are running the containers on:
Git
Podman with a running Podman machine on macOS or Windows
jqA Bob API key saved as JSON with a top-level
apikeyfield (get it from bob.ibm.com/admin when you signed up for a trial.)About two ☕️☕️ (one per container)
You do not need Java, Maven, Node.js, or Bob Shell on the host. I put those tools in the image so the host only needs Git, Podman, and jq. IBM’s installation documentation currently requires Node.js 24 or newer for a direct installation.
Bob let’s you chose two different types of API keys. For this example I use an inference-scoped key. A general key requires an additional team ID, which the runner reads from BOB_TEAM_ID without writing it to the repository. It helps to separate automation efforts in the Bobalytics interface but doesn’t help this demo, so we are skipping it.
Create a Clean Lab Repository
First, clone the article repository:
git clone https://github.com/myfear/the-main-thread.git
cp -R the-main-thread/bobshell-podman-worktrees/parallel-bob-lab parallel-bob-lab
cd parallel-bob-lab
git init -b main
git add .
git commit -m "Create parallel Bob lab"For this walkthrough, the lab is both the Git repository root and the Maven project root.
The baseline has one test per endpoint. We run them later inside the container. For now, confirm that the working tree is clean:
git status --shortThe command should print absolutely nothing.
Create One Worktree per Task
The linked checkouts live below .worktrees/, which is excluded in .gitignore. They stay close to the repository without appearing as untracked files in main.
Create both branches and worktrees:
mkdir -p .worktrees
git worktree add -b feature/catalog-search .worktrees/catalog-search
git worktree add -b feature/express-shipping .worktrees/express-shipping
git worktree listThe final command should show three rows with different branches:
/path/to/parallel-bob-lab [main]
/path/to/parallel-bob-lab/.worktrees/catalog-search [feature/catalog-search]
/path/to/parallel-bob-lab/.worktrees/express-shipping [feature/express-shipping]A linked worktree contains a .git file instead of a .git directory. That file points back to metadata under the main repository using an absolute path. This was my first container failure. I mounted the host repository at /workspace, entered a worktree, and Git followed the stored host path to a directory that did not exist in the container.
Be careful with the mounts and make sure that the repository is at the same absolute path on both sides. It looks redundant in the Podman command, but Git can resolve every worktree pointer without any path rewriting. My start script resolves the physical repository path and uses it for both sides of the bind mount.
Build the Bob Shell Image
Let’s pin Bob Shell at version 2.0.2 in the Containerfile and install the Java toolchain used by the lab:
FROM docker.io/library/node:24-trixie-slim
ARG BOBSHELL_VERSION=2.0.2
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
ca-certificates \
curl \
git \
jq \
maven \
openjdk-21-jdk-headless \
ripgrep \
&& rm -rf /var/lib/apt/lists/* \
&& curl --fail --silent --show-error --location \
https://bob.ibm.com/download/bobshell.sh \
--output /tmp/install-bobshell.sh \
&& bash /tmp/install-bobshell.sh --pm npm --version "${BOBSHELL_VERSION}" \
&& rm /tmp/install-bobshell.sh \
&& bob --version \
&& java -version \
&& mvn --version
COPY --chmod=0755 scripts/container-unzip.sh /usr/local/bin/unzip
WORKDIR /workspace
CMD ["sleep", "infinity"]I download the installer script before I execute it. Piping a network response directly into a shell inside an image build should work, but I don’t like supply-chains to be super complex or hard to inspect. The Bob installer verifies the package checksum and prints it during the build.
The slim Node image does not include unzip, and that exposed another failure I did not expect. Maven Wrapper 3.3.4 changes its ZIP URL to a tarball when unzip is missing, while the generated project still has the ZIP checksum. The checksum error looks like a compromised Maven download even though the downloaded archive is fine.
You can ad a small container-unzip.sh adapter instead of disabling checksum validation. It uses the JDK’s jar tool to extract the ZIP and restores executable bits on Maven’s scripts. What a time to be alive. And yes, I got this tip from my friendly coding agent :)
#!/bin/sh
set -eu
if [ "${1:-}" = "-q" ]; then
shift
fi
archive="${1:-}"
shift
if [ "${1:-}" != "-d" ] || [ -z "${2:-}" ]; then
echo "Usage: unzip [-q] <archive> -d <directory>" >&2
exit 2
fi
destination="$2"
mkdir -p "${destination}"
cd "${destination}"
jar xf "${archive}"
for executable in "${destination}"/apache-maven-*/bin/*; do
if [ -f "${executable}" ]; then
chmod +x "${executable}"
fi
doneBuild the image:
podman build \
--build-arg BOBSHELL_VERSION=2.0.2 \
--tag localhost/bob-worktree-lab:2.0.2 \
--file Containerfile .The .containerignore file keeps .git, linked worktrees, build output, result streams, and local secrets out of the build context. None of those files belong in the image sent to the build engine.
Start a Constrained Container
Start the long-lived container:
./scripts/start-sandbox.shFor unattended work, these controls are the boundary I think I might be able to trust. The script creates named volumes for Bob state and the Maven cache, then starts the container with:
a read-only container root filesystem
all Linux capabilities dropped
no-new-privilegesa limit of 512 processes, 4 GB of memory, and 4 CPUs
a writable, size-limited
/tmponly the repository, Bob state, and Maven cache mounted writable
the repository mounted at the same absolute path as the host
My first version of a container used a completely read-only approach. Bob failed before the first prompt because it writes settings and task state below /root/.bob. I keep the root filesystem read-only and mount one named volume at that path instead. Maven gets a separate volume for downloads.
All three writable mounts use Podman’s private SELinux relabeling:
--volume "${repository_root}:${repository_root}:rw,Z"
--volume "${bob_state_volume}:/root/.bob:rw,Z"
--volume "${maven_cache_volume}:/root/.m2:rw,Z"Podman’s volume-labeling documentation explains the “Z” as a private, unshared label. On an SELinux-enforcing Podman machine, files in a reused named volume can retain the previous container’s category. A replacement container then gets Permission denied when Maven tries to execute its cached launcher. Private relabeling moves the repository and both volumes into the current container’s security context. This lab runs one container at a time, so a private label is the right fit.
The script also runs the baseline test suite once. I added that step after two first-run wrappers raced while validating the same Maven download. Priming the cache before Bob starts removes that race and proves the starting application at the same time. Another concession I am willing to make to get all of this to work.
Expected ending:
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Primed the Maven cache and verified the baseline tests.Before I give Bob a key, I replace the container once and run the same baseline again:
./scripts/stop-sandbox.sh
./scripts/start-sandbox.shNow we proved that Bob state and the Maven distribution remain readable after Podman assigns a new container security context.
Let’s quickly check the resulting container:
podman inspect bob-worktree-lab --format '{{json .HostConfig}}' |
jq '{readonly_rootfs: .ReadonlyRootfs,
cap_drop: .CapDrop,
pids_limit: .PidsLimit,
memory_bytes: .Memory,
nano_cpus: .NanoCpus,
security_opt: .SecurityOpt,
tmpfs: .Tmpfs}'The output should contain readonly_rootfs: true, pids_limit: 512, memory_bytes: 4294967296, nano_cpus: 4000000000, and no-new-privileges. Podman expands --cap-drop ALL into the capability list shown by inspect.
Review the License and Load the API Key
Bob stores license acceptance in its state directory. You have to review it once before accepting it inside the named volume:
podman exec bob-worktree-lab sh -c \
'cat "$(npm root --global)"/bobshell/dist/ibm-licence/*.txt'
podman exec bob-worktree-lab bob --accept-license --versionIBM’s non-interactive CLI documentation lists bob --show-license for this job. The 2.0.2 build I tested rejects that command because it expects a prompt, so I read the installed license files directly. npm root --global resolves the installation directory instead of hard-coding /usr/local/lib/node_modules.
The tested image prints:
2.0.2
commit: a31a75e3When you create a new API key you can save the downloaded key as JSON file. I keep it outside the repository as $HOME/.config/bob-lab/key.json, so I can restrict and validate it:
chmod 600 "$HOME/.config/bob-lab/key.json"
export BOB_KEY_FILE="$HOME/.config/bob-lab/key.json"
jq -e '.apikey | type == "string" and length > 0' \
"$BOB_KEY_FILE" >/dev/nullDid I already mention that I have trust issues? Yes, and this feeling is even stronger with any kind of key. So we do not mount it into the container. scripts/run-bob.sh reads its apikey value and passes BOB_API_KEY only to the podman exec process that starts Bob. That keeps the key out of Git and the long-lived container configuration.
BOB_API_KEY is still visible to commands running as the same user inside the Bob process. I use a narrowly scoped key, and I do not give the agent a reason to print its environment. Yes. I know. But again, better save than sorry.
Bound the Two Bob Tasks
Bob Shell 2.x uses bob run for non-interactive work. And the Bob documentation has another big trigger for my trust issues: It says that tool calls are approved automatically. Which means that I can not let this run over night without the fear that the agent might get caught in a loop burning all my token for nothing. So I am putting limits around my sessions:
bob run
--format stream-json
--workspace "$worktree_path"
--mode agent
--max-turns 20
--max-cost 2
--disable-mcp
--disable-subagents
--trust--max-turns 20 caps the agent loop at 20 turns, and --max-cost 2 sets a two-Bobcoin cap.
Bobcoins. Do not ask me about the name. It basically is an abstraction over token and money. I think I have read a mapping somewhere and it roughly was $0.5 equals 1 Bobcoin.
Let’s disable MCP and subagents so we keep this example focussed on containers and nothing else. --trust handles the disposable workspace non-interactively. The Bob tools documentation lists the current tool groups and their command-line controls in case you need more information.
I also make each prompt narrow enough to review after the run. The catalog task says:
Add optional catalog search to this Quarkus application.
Requirements:
- GET /catalog must continue to return all three items.
- GET /catalog?q=robot must return only the item named Robot arm.
- Search must be case-insensitive and must match a substring in either the SKU or the name.
- A missing, empty, or blank q value must return all items.
- Add tests for the new behavior.
- Edit only CatalogResource.java and CatalogResourceTest.java.
- Run ./mvnw test before you finish.
- Do not commit the changes.The file in prompts/catalog-search.md contains the full paths. prompts/express-shipping.md uses the same shape for standard, express, and invalid shipping speeds.
That’s basicually instructions for the agent. For now we can not enforce this obviously. Bob has a shell in a writable repository mount. So I created verify-worktrees.sh to check the files Bob changed after both runs.
Run Both Worktrees at Once
We use Bob’s newline-delimited JSON output to inspect the run after the process exits:
mkdir -p bob-resultsOpen two terminals in the lab repository. Start the catalog task in the first:
./scripts/run-bob.sh \
.worktrees/catalog-search \
prompts/catalog-search.md \
8081 > bob-results/catalog.ndjsonThen the shipping task in the second:
./scripts/run-bob.sh \
.worktrees/express-shipping \
prompts/express-shipping.md \
8181 > bob-results/shipping.ndjsonThe last argument becomes QUARKUS_HTTP_TEST_PORT for that Bob process and every command it launches. Separate source trees isolate files, not TCP ports, so two concurrent Quarkus suites cannot both own port 8081 on the same machine.
Both Bob processes still share the container’s CPU, memory, network, Maven cache, and Bob state.
Verify the Agent Results
After both processes exit, you can read the result events from their streams:
jq -r '
select(.type == "result") |
[.status,
(.stats.duration_ms | tostring),
(.stats.session_costs | tostring),
(.stats.tool_calls | tostring)] |
@tsv
' bob-results/*.ndjsonMy two live runs returned:
success 56205 0.41585000000000005 16
success 37029 0.270708 11Duration, Bobcoin cost, and tool-call counts will vary. And we agree that calling it success isn’t an approval to merge the canges, right?. It only tells us that Bob finished its own loop, so right after is the perfect time to inspect the files and run the tests independently and manually.
Run the verifier:
./scripts/verify-worktrees.shThe verifier performs three checks before the review:
the catalog worktree changed only
CatalogResource.javaandCatalogResourceTest.javathe shipping worktree changed only
ShippingQuoteResource.javaandShippingQuoteResourceTest.javaboth diffs pass
git diff --checkand both complete test suites pass inside the container
My general approach is to keep the blast radius as small as possible and limit changes to what is absolutely necessary.
The catalog worktree finished with six passing tests:
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESSThe shipping worktree finished with five:
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Verified both worktrees and their expected file boundaries.You should inspect both diffs before committing anything:
git -C .worktrees/catalog-search diff
git -C .worktrees/express-shipping diffGreen tests tell you that the specified examples work. As developers we still own the results of agentic development so we should at least try to understand the changes. I know. It can be a lot. And we still haven’t figured out how to not burn out, but that is going to be another article at some point.
Merge the Reviewed Branches
After review, I commit inside each worktree:
git -C .worktrees/catalog-search add \
src/main/java/com/mainthread/catalog/CatalogResource.java \
src/test/java/com/mainthread/catalog/CatalogResourceTest.java
git -C .worktrees/catalog-search commit -m "Add catalog search"
git -C .worktrees/express-shipping add \
src/main/java/com/mainthread/shipping/ShippingQuoteResource.java \
src/test/java/com/mainthread/shipping/ShippingQuoteResourceTest.java
git -C .worktrees/express-shipping commit -m "Add express shipping"Back on main, I merge both branches and run the combined suite in the container:
git merge --no-ff feature/catalog-search -m "Merge catalog search"
git merge --no-ff feature/express-shipping -m "Merge express shipping"
podman exec \
--workdir "$(pwd -P)" \
--env QUARKUS_HTTP_TEST_PORT=8281 \
bob-worktree-lab \
./mvnw --batch-mode --no-transfer-progress testThe changes were independent, so they merged without a conflict. The combined application passed all nine tests:
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESSRemove the container when you are done:
./scripts/stop-sandbox.shI leave the named Bob and Maven volumes in place for the next run. The Bob volume retains its task database and logs as well as license acceptance, so I treat it as run history rather than disposable cache. To discard that history and the downloaded Maven dependencies after removing the container, delete both volumes explicitly:
podman volume rm bob-worktree-lab-state bob-worktree-lab-mavenUnderstand the Boundary You Built
At this point I have the separation I wanted for routine development work. Each task has its own branch, index, checkout, and target/ directory. The container keeps Bob away from unmounted host files and applies process and resource limits. The API key stays outside the repository, and the result streams give me something concrete to inspect after the run.
Both worktrees live below one writable repository mount, so a shell command can enter the main checkout or a sibling worktree. Prompt restrictions and .bobignore do not change that. IBM’s security guidance makes the same distinction for .bobignore: it reduces accidental context exposure, but it is not a system sandbox.
The container also has network access because Bob and Maven need it. Source that Bob reads can be sent to the model service, and commands can reach other allowed network destinations. I only use this setup for repositories I am authorized to process with Bob.
If I had to run hostile code, or if one task must not see another task’s source, I would use one container or virtual machine per worktree and expose only the Git metadata that checkout needs. That design is stronger and more complicated. For trusted development tasks, worktrees plus one constrained Podman container separate change state without claiming to isolate the agents from each other.
Conclusion
I got the unattended parallel runs I wanted, and I did not have to give Bob my main checkout or the rest of the host filesystem.
I still do not trust an unattended agent just because it now can run in a container. And my trust issues are only partly addressed. But that might be more a personal than a professional problem of mine :) We can note that: worktrees prevent routine checkout collisions, Podman limits host exposure, Bob’s flags cap the session, and my own path checks and tests decide whether the result is worth merging. That is enough trust for this workflow.


