- Python 86.9%
- Dockerfile 9.7%
- Shell 3.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| docker | ||
| src | ||
| tests | ||
| CHANGELOG.md | ||
| README.md | ||
craft-workbench
A sandboxed reverse-engineering workbench. Instead of an agent calling RE tools one at a time (and filling
its context with disassembly), it hands over a whole job: a target and a brief. A coding agent inside
this container works the job with Craft Anything's CLI,
Ghidra and the usual shell tools, keeps the bulky output on disk, and hands back a report.md.
Gideon / TV ──► LiteLLM ──► craft-workbench :8001/mcp (one port per lab)
│ submit_job · job_status · get_report · list_targets · list_jobs · cancel_job
▼
per-job folder, run as the lab's own Linux user
OpenCode or Claude Code ──► ca (Craft Anything, Deep mode) · Ghidra · rg · jq …
│ model calls go back out through LiteLLM only
▼
Reports/<lab>/<job-id>/report.md, evidence-report.md, artifacts/, agent.log
What's inside: Craft Anything CLI (built from upstream main), Ghidra 12.1.4 + JDK 21, headless Chromium,
OpenCode, Claude Code, ripgrep, jq, binutils, file, xxd, Python 3.
Repo layout
Standard jriyuu-labs layout (from template-default):
craft-workbench/
├── .forgejo/workflows/build.yml build and push; checks upstream every 6 hours
├── src/ the job runner, copied to /opt/workbench in the image
│ ├── workbench/ Python package: config, setup, MCP job API
│ ├── entrypoint.sh
│ ├── job_instructions.md the AGENTS.md each job gets
│ ├── labs.example.yaml
│ ├── requirements.txt
│ └── bin/ca, bin/chromium wrappers installed to /usr/local/bin
├── docker/
│ ├── Dockerfile build context is the repo root
│ ├── compose.yaml for plain Docker hosts
│ └── craft-workbench.xml Unraid template
├── tests/mock_litellm.py fake LiteLLM for running a job end to end
├── CHANGELOG.md
└── README.md
This repo has no web UI, so the template's Bifrost UI files (.bifrost-ui, scripts/fetch-ui.*,
src/web/) and its fetch step aren't used.
Setup
1. Network (this is what takes the internet away from jobs)
Create an internal Docker network and attach LiteLLM to it as a second network:
docker network create --internal workbench-internal
docker network connect workbench-internal litellm
--internal means containers on it can only reach each other. LiteLLM keeps its normal network for
internet and for OpenClaw; the workbench can reach only LiteLLM. On Unraid, a docker network connect
is lost when LiteLLM's container is recreated (template edit or update), so add this to LiteLLM's
template under Post Arguments:
; docker network connect workbench-internal litellm
You can run the workbench on a normal network instead and everything works, but jobs then have internet access.
2. Folders
/mnt/user/MCP-Tools/Workbench/In/<lab>/ drop targets here (read-only to the container)
/mnt/user/MCP-Tools/Workbench/Reports/ results appear here per lab and job
/mnt/cache/appdata/mcp_and_tools/craft-workbench/ labs.yaml, job folders, caches
Create an In/<lab> folder for each lab. The rest is created on start.
3. Container
On a plain Docker host, use docker/compose.yaml instead.
Add docker/craft-workbench.xml as a template (or copy its settings) and start it once. With no config,
it writes /data/labs.yaml from the example and waits. Edit it, then restart.
4. labs.yaml
public_gid: 100
litellm:
base_url: http://litellm:4000
models: [claude-sonnet, claude-opus, local-coder]
defaults:
agent: opencode
model: claude-sonnet
labs:
jriyuu-labs: { uid: 2001, port: 8001, litellm_key: sk-..., api_token: <openssl rand -hex 32> }
ducky-labs: { uid: 2002, port: 8002, litellm_key: sk-..., api_token: <another one>, models: [local-coder, claude-sonnet], model: local-coder }
modelsare LiteLLMmodel_names. A lab can be limited to a subset.litellm_key: make one LiteLLM virtual key per lab, limited to that lab's models. Usage then shows per lab in LiteLLM, and the key is the only credential a job ever sees.api_token: a shared secret LiteLLM sends with every call to this lab (see step 5). Without it, any container on the network can use the lab's API; the server logs a warning.agent:opencodeworks with any model LiteLLM serves.claude-codeneeds Claude models (it talks to LiteLLM's Anthropic-format/v1/messages). Both are installed; a job can pick either.
The file is made root-only (0600) on every start, since it holds the keys.
5. LiteLLM
Add one MCP server per lab, the same way as REA:
mcp_servers:
Gideon_Workbench:
url: "http://craft-workbench:8001/mcp"
transport: "http"
auth_type: "bearer_token"
auth_value: os.environ/WORKBENCH_TOKEN_JRIYUU # the lab's api_token, without "Bearer"
description: "Hand whole reverse-engineering jobs to a sandboxed agent (jriyuu-labs)"
TV_Workbench:
url: "http://craft-workbench:8002/mcp"
transport: "http"
auth_type: "bearer_token"
auth_value: os.environ/WORKBENCH_TOKEN_DUCKY
description: "Hand whole reverse-engineering jobs to a sandboxed agent (ducky-labs)"
No host ports are published: LiteLLM reaches it by container name on workbench-internal.
Using it
- Put the target in
In/<lab>/(a file, or a folder such as an extracted APK or app bundle). - The agent calls
list_targets, thensubmit_job(target, brief). A good brief says what you want to know and how deep, e.g. "How does this app check its licence? Find the check, explain the logic, and say what a valid key looks like." - It checks
job_statusevery few minutes (it shows the last few lines of activity), thenget_report.
Everything for a job also lands in Reports/<lab>/<job-id>/, so you can read it over SMB:
report.md (the agent's answer), evidence-report.md (Craft Anything's evidence ledger), artifacts/,
agent.log, job.json.
Optional submit_job arguments: model, agent (opencode / claude-code), timeout_minutes.
How labs are kept apart
Each lab is a Linux user created at start-up from labs.yaml (uid = the lab's uid), and every job runs
as that user with no extra groups (setpriv --no-new-privs). The job API itself is the only thing
running as root.
| Path | Owner : group | Mode | Who gets in |
|---|---|---|---|
/data/labs/<lab>, …/work, /reports/<lab> |
root : public_gid |
2751 / 2771 |
root and you (group); the lab can only pass through |
…/work/<job-id>, /reports/<lab>/<job-id> |
lab : public_gid |
2770 |
that lab's job; you over SMB via the group |
/data/labs/<lab>/home |
lab : lab | 0700 |
that lab only (Craft Anything settings, Ghidra cache) |
/etc/workbench/<lab>/opencode.json |
root : lab | 0640 |
the lab can read its agent config, not change it |
/srv (holds the In mount) |
root | 0700 |
root only: jobs get a private copy of their target |
/data/labs.yaml, /data/state |
root | 0600 / 0700 |
root only |
The parent folders are root-owned on purpose: a job owns its own job folder and nothing around it, so it
can't plant a symlink where root is about to create or write something. Root also never follows a
symlink when it sets up folders or writes agent.log and job.json.
Lab users are deliberately not in public_gid, so the group is how you reach every lab's files from
the host while the labs can't reach each other's. setgid on the folders (2770) keeps new files in that
group, and jobs run with umask 007, so reports stay readable and editable over SMB.
Who can call a lab's API. Jobs run in the same container as every lab's server, so the API refuses
any request that comes from inside the container (loopback or the container's own address). That stops a
job from submitting work on another lab's key or reading its reports. On top of that, each lab's
api_token must arrive as a bearer token, which keeps other containers on the network out. Only
/health is open.
Other guard rails: no network apart from LiteLLM, inputs read-only, the copied report is published by the lab user (so it can only contain what that lab could read) with symlinks removed, and once a lab has no job running, anything still running as that lab's user is killed (including processes that tried to detach themselves).
Adding a lab
Add an entry to labs.yaml with an unused uid (2000–59999) and port, create In/<new-lab>, restart,
and add the new endpoint to LiteLLM. Never change an existing lab's uid: files on disk are owned by
the number, so a changed uid locks the lab out of its own history.
Sizing memory
Every running job can start a Ghidra analysis with a GHIDRA_HEADLESS_MAXMEM heap (4G by default), plus
the agent and maybe Chromium. Size the container's memory limit for the jobs that can run at once:
roughly sum of max_concurrent across labs × (Ghidra heap + 1G). Two labs at max_concurrent: 2 and 4G
heaps want about 20G; lower max_concurrent or the heap if that's more than you want to give it.
Testing locally
tests/mock_litellm.py stands in for LiteLLM so a job runs end to end without a model. Because the test
calls the API from the same machine, start the server with WORKBENCH_UNSAFE_ALLOW_LOCAL=1, which turns
off the inside-the-container check. Never set it in a real deployment.
Updates
The workflow tracks Craft Anything's main (it has no releases yet):
- Every 6 hours it compares upstream's commit and the latest OpenCode and Claude Code versions with the
labels on the published
:latest, and only builds if something changed. - On push and manually it always builds. A manual run takes an optional
craft_ref(commit, tag or branch) to build something specific. - Images are tagged
:latestand:<short-sha>of the Craft Anything commit. If an update breaks something, point the template at the previous:<short-sha>tag. - The Rust dependency layer is cached in the registry (
:buildcache, via cargo-chef), so a rebuild usually recompiles only Craft Anything itself.
Sharing it
Nothing in the image is specific to this homelab. Someone else needs: the image, a labs.yaml with their
own labs, uids, ports, LiteLLM URL and keys, public_gid set to the group their host user is in (Unraid:
100; plain Linux: usually 1000), and the three mounts. Without LiteLLM, any OpenAI-compatible endpoint
works for OpenCode as litellm.base_url (the provider is just named "litellm").
Limits worth knowing
- Live website study needs internet. With
workbench-internal, Craft Anything's browser tools work on saved pages and local files only. Run on a normal network if you need live sites. - No device tools. Frida, adb, gdb and lldb aren't installed; this is static analysis plus whatever runs offline.
- amd64 only.
- Jobs can't ask questions. The brief is all they get; they note their assumptions in the report.
- Craft Anything is early alpha and changes daily. If jobs start failing after an update, check
agent.logand roll back to the previous image tag.