July 3, 2026
8 min read
ai / devops / workflow
From a Bash Loop to Guarded Orchestration for AI Agents
My AI workflow had careful task plans and a careless execution layer: an agent with bypass flags running against my live checkout. The guarded runner I built puts each task in a disposable container and brings work back only as Git patches. What it protects against, what it does not, and what the tests actually prove.

My AI workflow had PRDs, task plans with acceptance criteria, project memory files and a TDD loop. The command that executed those tasks was a Bash loop around codex exec --dangerously-bypass-approvals-and-sandbox, running directly against my live checkout.
That mismatch is what this post is about. The planning side had become careful, and the execution side still gave an agent with no approval prompts write access to the repository I work in and whatever my user account could reach on the machine. I built a guarded runner to close that gap. It runs one task at a time in a disposable, hardened container, and the only intended way for work to come back is as Git patches.
What follows covers the threat the runner addresses, how the boundary works, what the tests prove, and what is still missing. The runner lives in the orchestration subproject of my skills repository and is still early.
What I was protecting against
The original loop had three concrete exposures:
- A mistaken edit landing in the principal checkout. The agent wrote directly to the working tree I was using.
- A destructive command reaching the host. With approvals and sandboxing bypassed, a bad
rmor a misdirected script ran with my permissions. - Unreviewed persistence. Anything the agent changed, including files outside Git's view, stayed changed.
The runner addresses the second and third fully, and the first only partly: the agent can no longer write to the checkout directly, but committed work is still promoted into it automatically, as described below. It is not designed against a deliberately hostile agent trying to escape a container, and I do not claim it resists one. The goal is to make mistakes contained and visible, not to build a security sandbox for untrusted code.
The boundary: a snapshot in, patches out
Before a run, the runner requires a clean principal checkout, at least one task marked Status: ready or Status: planned in docs/TASKS.md or docs/tasks/*.md, and both treehouse and Docker available.
One iteration then works like this:
- The host runner confirms there is a runnable task entry. The agent chooses which one and records its choice.
- It leases an isolated worktree through
treehouse. - It clones that worktree into a disposable snapshot repository and overlays the working tree onto it, so local generated files are available without mounting the real worktree.
- It writes a prompt that tells the agent to use repository files only, pick exactly one runnable task, update the task plan, write the task metadata artifacts and stop.
- It starts a hardened container with the snapshot and a narrow artifacts directory mounted.
- Inside the container, a helper runs the agent, commits leftover changes if the agent did not, and exports the result with
git format-patch. - On the host, the runner imports the patch series into a branch named
agent/<task-id>-<run-id>. - If the import succeeds, the host fast-forwards that branch into the principal checkout.
- The lease is returned. If
--max-tasksallows another task, the next one starts from a freshly leased worktree.
The key property is in step 5: the agent never gets a writable mount of the principal checkout, or even of the leased worktree. It writes to a throwaway snapshot. The only intended path back to the host is Git history plus three artifacts:
selected-task-id.txtlast-commit-message.txtpatches/*.patch
If a required artifact is missing, the run fails before anything is imported.
Hardening the container
The default docker run posture (an excerpt: image and mounts omitted):
docker run --rm --init \
--network none \
--cap-drop=ALL \
--security-opt no-new-privileges \
--pids-limit 512 \
--memory 4g \
--cpus 4
The image runs as the non-root node user. Its entrypoint checks the prompt and artifact requirements before and after the agent runs.
--network none deserves a note, because it creates real friction. A hosted Codex run needs the network to reach the model API, so it requires an explicit override such as GUARD_NETWORK_MODE=bridge. I found that while implementing the runner, and I kept the restrictive default anyway. Network access changes the risk profile, and I want that change to be a visible, deliberate flag on the command line, not an invisible default. It also means the posture above is the default, not what a hosted run actually gets: with bridge, the container can reach the network, and the agent needs model credentials inside it.
What the tests prove, and what they do not
The test suite is black-box. It stubs treehouse and docker, invokes the public CLI and asserts on behavior:
- the hardened Docker flags are present;
- the container does not receive the leased worktree as a writable mount;
- a run with missing patch exports fails;
- a run with missing commit metadata fails;
- a dirty principal checkout is rejected;
--max-tasks 2acquires a fresh lease for the second task instead of reusing state.
These tests prove that the runner keeps its contract: it passes the right flags, refuses the right states and does not mount the worktree into the container as writable. Orchestration bugs tend to sit in the seams between Git state, task state and runtime state, which is why I wanted them pinned by tests rather than verified by reading the script.
They do not prove container isolation. With Docker stubbed, nothing in the suite exercises a real container, let alone an escape attempt.
The other limit is in the flow itself. Step 8 promotes the imported branch automatically. The agent/... branch makes the change easy to inspect, but it is not a human approval gate before merge. Imported code still has to be validated before it runs on the host, and today that responsibility sits with me, not with the runner.
How it was built
I built the runner in small, tested stages, and the task-run history records the order:
- a black-box test seam around fake
treehouseanddocker; - the host-side artifact contract: exported patches and commit metadata;
- stricter container-side export rules;
- the documented operator contract and
--help; - bounded multi-task runs with
--max-tasks, with a fresh lease per task.
The subproject has its own PROJECT.md, CONTEXT.md, docs/PRD.md, docs/TASKS.md and an ADR describing the contract. A tool that runs my repository-driven workflow should be held to the same standard as the projects it runs.
This also explains why the runner can stay simple: it does not decide what to build. PROJECT.md, CONTEXT.md, the PRD and the task plan already define the work, so the prompt only has to say "read these files, pick one runnable task, do only that, record what you picked, stop". The design leans on the rest of the workflow being written down. In a repository without that structure, I would not expect it to work as well.
What it costs
The guarded path is slower than the direct one and has more moving parts: leases, snapshots, patch export and import, merge and cleanup on failure. Untracked caches do not survive between runs, and container state disappears after each task. The artifact rules add failure modes that the direct loop never had.
I accept that cost because the direct loop's simplicity was hiding its risk. Running an agent with bypass flags in the current directory is one short command, and its blast radius is everything my user account can reach.
What is still missing
The orchestration contract works, but the tool around it is rough:
- no good way to watch a run, or to inspect one afterwards: selected task, artifacts, validation output, imported branch, failure reason;
- most of the flow lives in one large host-side Bash script, which I would like to replace with a sturdier implementation;
- promoting, discarding, inspecting and rolling back agent branches is still manual;
- no clear credential story yet for local versus hosted agent runs, including what a
bridgerun exposes and whether untracked files such as.envreach the snapshot through the working-tree overlay; - run history is mostly temporary directories and log files.
Of these gaps, promotion is the one that changes the safety story. As long as imported branches fast-forward automatically, the runner contains where an agent writes, but it does not yet control what reaches my main checkout. Making promotion an explicit, reviewable decision is what would move the runner from a safer loop toward a tool I can trust, inspect and improve deliberately.