Craft-Anything In Docker
  • Python 86.9%
  • Dockerfile 9.7%
  • Shell 3.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jriyuu 01f33e200a
All checks were successful
Build craft-workbench image / build (push) Successful in 7m14s
checks / lint (push) Successful in 22s
Merge pull request 'int-upload' (#1) from int-upload into main
2026-11-02 13:08:37 +11:00
.forgejo/workflows Upload files to ".forgejo/workflows" 2026-11-02 11:39:26 +11:00
docker Upload files to "docker" 2026-11-02 11:38:51 +11:00
src Upload files to "src/workbench" 2026-11-02 12:21:09 +11:00
tests Upload files to "tests" 2026-11-02 11:37:55 +11:00
CHANGELOG.md Upload files to "/" 2026-11-02 09:13:04 +11:00
README.md Upload files to "/" 2026-11-02 11:39:13 +11:00

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 }
  • models are LiteLLM model_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: opencode works with any model LiteLLM serves. claude-code needs 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

  1. Put the target in In/<lab>/ (a file, or a folder such as an extracted APK or app bundle).
  2. The agent calls list_targets, then submit_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."
  3. It checks job_status every few minutes (it shows the last few lines of activity), then get_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 :latest and :<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.log and roll back to the previous image tag.