Fenzi

Use

CI with Fenzi

An agent in a CI job has nobody at its terminal. Fenzi runs it in a sandbox on the runner, under the same policy a laptop runs. Its commits come back as a branch for review, and the job's summary names every call the policy refused.

Command: fenzi run --headlessGitHub ActionFree and Team

Why a runner

A laptop is the developer's machine. A runner is the job's. On a laptop the developer administers the sandbox runtime. On a GitHub-hosted runner the job installs it, runs one agent, and the machine goes away with the job. On Team the policy is the signed revision every laptop runs, and nobody in the job can approve a request.

Claude Code, Codex, OpenCode and Grok today. Agents run with an API key; a subscription sign-in needs a person at a browser, so it does not work here.

The GitHub Action

The action installs fenzi and the sandbox runtime at the release fenzi is built for, signs in to your team when you give it a token, runs the agent, and keeps the result as an artifact.

.github/workflows/agent.yml
on:
  workflow_dispatch:
  schedule:
    - cron: '0 6 * * 1'

permissions:
  contents: read

jobs:
  agent:
    runs-on: ubuntu-24.04
    timeout-minutes: 45
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: fenzi-inc/fenzi/action@main
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        with:
          agent: claude
          task-file: .github/agent-task.md
          timeout: 30m
          team-token: ${{ secrets.FENZI_TEAM_TOKEN }}

Pin the action to a commit rather than main. The model key goes in the step's env, from a secret. It reaches the sandbox as the agent's key does on a laptop: the agent sees a placeholder. Leave out team-token to run on Free, with the fenzi.yaml in the repository or developer-default.

It runs on GitHub's Ubuntu runners, and on Linux runners of your own with Docker. Not on macOS runners: the runtime's installer there needs Homebrew and a password.

Without the action

Any CI system that gives you a Linux machine with Docker can run the same commands.

fenzi setup --runtime
fenzi run --headless --task task.md --timeout 30m --out fenzi-out claude

setup --runtime installs the pinned runtime if it is missing, with no questions, and waits for its gateway. On a runner that keeps an older patch, it upgrades only when no sandbox is running, because the upgrade restarts the gateway; otherwise it keeps the installed release and says which sandboxes held it back. --task takes a file, or - for standard input; --task-text takes the task itself. The task goes to the agent as its prompt.

Example output
requests  nobody answers them on Free; a request the agent files stays refused and fails the run
start     claude --permission-mode bypassPermissions -p <task, 212 characters>
agent     exited with status 0
log       saved, fenzi log
git       2 commits by Claude Code on fenzi/web-4f2a, now in your repository
          main is unchanged until fenzi apply
changes   3 files staged, not applied
result    finished, exit 0
refused   1 call
            POST api.github.com/markdown: L7_REQUEST deny POST api.github.com:443/markdown reason=POST /markdown not permitted by policy
sandbox   fenzi-web-4f2a, deleted
summary   summary.json and summary.md, and the branch in branch.bundle written to fenzi-out

The agent's own mode

Each agent runs in the non-interactive mode it already has. Its own permission prompts are off, because nobody would answer them. The sandbox's policy decides what each command can reach, as it does in every mode.

AgentStarts as
Claude Codeclaude --permission-mode bypassPermissions -p <task>
Codexcodex … exec --skip-git-repo-check <task>
OpenCodeopencode run --auto <task>
Grokgrok --permission-mode bypassPermissions --single=<task>

To keep the agent's own check as well, pass the flag after the agent's name: fenzi run --headless --task task.md claude -- --permission-mode auto.

Requests while nobody watches

Nothing approves itself. When the agent needs a host the policy does not allow, it can file a request, as it does on a laptop.

  • On Team the request goes to the team console while the job runs, and a reviewer answers it there. --request-timeout 10m bounds the wait: with no answer by then, Fenzi refuses the request with a reason the agent reads, and the agent carries on without the access. Without it, a request waits until the time limit.
  • On Free nobody can answer. The run says so at the start, and the agent is told not to file requests. The policy refuses what it always refuses, and nothing more.

A request that never got an answer fails the run with exit status 6, so the job shows it.

Time limit and exit status

--timeout covers the whole run, 30 minutes unless you say otherwise. At the limit the agent is stopped, its commits and files still come back, and the sandbox is deleted.

StatusMeans
0The agent finished.
1, 2, 3The run did not start: an error, a policy the prover refused, or a gateway that is not ready. Without a verified team policy the run does not start.
4The agent exited with an error of its own.
5The time limit came first.
6A request had no answer.

Refused calls do not fail the run by themselves: refusing them is the policy's job. They are listed in the summary.

What comes back

The agent's commits land as a branch in the runner's checkout, through the same round trip as on a laptop (Workspace and changes). The checkout itself is never changed. --out writes three files, and the action keeps them as the job's artifact:

  • summary.md, the job summary: the result, the policy revision, the branch, every refused call, and every request with its answer.
  • summary.json, the same as data.
  • branch.bundle, the agent's commits. git fetch branch.bundle <branch>:<branch> brings them into any clone.

Pushing the branch is up to your workflow, with its own token, in a later step. The sandbox never holds a git token.

The round trip needs the whole history. actions/checkout clones one commit unless you set fetch-depth: 0; the action fetches the rest itself when it can. A shallow clone otherwise comes back as files, and the summary says so.

The team token

A runner signs in with a token, because nobody can open a browser on it. Make one on your own machine. You approve the code in the browser, so the token belongs to you.

fenzi login --token-for github-actions | gh secret set FENZI_TEAM_TOKEN

The token goes to standard output and is not stored on your machine, which stays signed in as it was. The console lists the runner as a machine of yours. Runs with the token count against your seat; there is no separate price for CI. Signing the machine out in the console ends the token.

In the job, the token reaches fenzi login --token-stdin on standard input, never on a command line. The runner is signed out when the step ends.

Not done yet

  • The runtime and all four agents have started on a GitHub-hosted runner, with the sandbox's kernel rules enforced. A full unattended run with a real model key, a refused call and a request answered in the console has not been recorded on a runner yet.
  • The console does not yet show which job filed a request.
  • A run record the pull request can check comes later.
What it calls upstream

The agent starts with openshell sandbox exec --tty, as an interactive run does. While it runs, openshell rule get --status pending every five seconds; a request that waited past --request-timeout gets openshell rule reject with a reason. Nothing calls rule approve without a person.