Getting started

TipJust want the shortest path for your OS?

Follow the focused platform guide, then come back here for the full model: Linux · Guix · macOS · Windows. Each covers the prerequisites, install, and — importantly — where your config file lives on that OS (macOS in particular puts it under ~/Library/Application Support, not ~/.config).

1. Build (manual path)

go build -o wraptool .

On Guix systems, the bundled manifest gives you a reproducible toolchain:

guix time-machine -C channels.scm -- shell -m manifest.scm -- go build -o wraptool .

2. Create a config

wraptool reads YAML from ~/.config/wraptool/config.yaml (XDG, with --config / WRAPTOOL_CONFIG overrides).

You usually don’t write this from scratch: on first run both wraptool up and wraptool serve scaffold a commented starter config at that path if none exists (never overwriting one that does) and log where it landed. The starter enables a single privileged tool — git pull — so the server comes up immediately; you then edit the file to expose the tools you want.

Only wrap commands that actually need wraptool. git pull, git fetch, and git push reach the network with your host credentials — which the AI never sees — so they must go through wraptool. Read-only local git (status, log, diff) only touches the working .git, needs no credentials, and the assistant can run it itself, so there is nothing to gain by wrapping it. A fuller policy:

mcp:
  transport: sse
  listen: 127.0.0.1:8717

tools:
  git:
    binary: /usr/bin/git
    timeout: 30s
    allow:
      - subcommand: [pull]
        flags: ["--ff-only", "--rebase"]
      - subcommand: [fetch]
        flags: ["--prune", "--all", "--depth"]
        flag_constraints:
          "--depth": { type: integer, max: 100 }
      - subcommand: [push]
        flags: ["--set-upstream"]
        deny_flags: ["--force", "--force-with-lease"]
    deny:
      - subcommand: [remote, set-url]

This policy wraps a CLI binary. Wraptool can also spawn credentialed local stdio MCP servers and expose selected tool names; see Proxy local MCP servers. Both capability sources appear on the same client-facing MCP connection.

See Configuration reference for every field.

3. Start the server

wraptool server start

The available transports:

Transport Config Use case
sse transport: sse, listen: host:port Independent process, TCP
unix transport: unix, listen: /path/to.sock Independent process, Unix socket

The TCP listener (sse/unix) serves both MCP HTTP transports on one port: legacy SSE at /sse (Claude Code, Cursor) and Streamable HTTP at /mcp (the 2025-03 transport — Antigravity and other clients that POST initialize to the URL). One Bearer-auth wrapper guards both; you don’t choose — each harness’s generated config points at the endpoint it speaks.

For SSE and Unix transports, add an auth token:

mcp:
  transport: sse
  listen: 127.0.0.1:8717
  auth_token_file: ~/.config/wraptool/auth_token
head -c 32 /dev/urandom | base64 > ~/.config/wraptool/auth_token

4. Connect your coding assistant

wraptool init --harness claude --harness cursor --url http://127.0.0.1:8717/sse

This writes the MCP config block for each harness. Supported values: agy, claude, cursor, opencode, gemini, pi, roo, or all.

init reads auth_token_file from your config and embeds the Authorization: Bearer header in every networked harness config — so an auth-enabled server works out of the box, no hand-editing. With no auth_token_file (localhost-trust) no header is written. agy (Antigravity) speaks Streamable HTTP, so its serverUrl targets /mcp rather than /sse; all others are written to the endpoint they use.

Important

A harness running inside a container (e.g. Antigravity in its own container) cannot reach the server at 127.0.0.1 — that’s the container’s own loopback. Use the host’s docker-gateway address instead (typically 172.17.0.1), and bind the server where the container can reach it (listen: 0.0.0.0:8717 or the gateway), not 127.0.0.1:

wraptool init --harness agy --url http://172.17.0.1:8717/sse

wraptool up’s devcontainer feature auto-detects this gateway for you; the manual init above is only needed when you wire a container-side harness yourself.

5. Smoke test

sequenceDiagram
  participant AI as AI assistant
  participant W  as wraptool
  participant G  as git
  AI->>W: tools/call git_pull
  W->>W: policy.Evaluate
  W->>G: exec /usr/bin/git pull (with host credentials)
  G-->>W: stdout
  W->>W: scrub credentials
  W-->>AI: ToolResult

Trigger any allowed subcommand from your assistant. The wraptool log prints one line per executed command with status and duration.

6. One command per project: wraptool up

Once the config exists you don’t start things by hand. In any git repository (or worktree) root:

wraptool up

Running bare wraptool in a repository does the same thing. up is idempotent and does three things:

  1. Ensures the shared server is running — if it isn’t, it starts wraptool serve detached (pidfile + lock under $XDG_RUNTIME_DIR, auto-generating the auth token), and waits for it to become reachable. It also brings up the operator review UI at 127.0.0.1:8718 with its own generated token (so you can approve the AI’s capability requests) and prints its URL — disable with --no-admin. The review UI is host-facing and always stays on loopback.
  2. Brings up the project’s container, injecting the connection info: the auth token and this worktree’s host path. On a Guix host it uses a native Guix container built from your project’s manifest.scm; elsewhere it falls back to devcontainer up (see below).
  3. Drops you into a shell in the container so you can start developing immediately. Exiting the shell leaves the container running; re-enter with wraptool up.

Which container runtime?

up auto-selects (and prints) the backend:

  • guix — when guix is on PATH and the worktree has a manifest.scm. It runs guix shell --container --network -m manifest.scm: no Docker, no image build, and the container gets the same toolchain you develop with (from manifest.scm), so the AI harness can compile, test, and analyze locally. The harness itself installs once into the shared pool ($XDG_DATA_HOME/wraptool/harness-pool/home, mounted to /home/wraptool-harness) — its login and skills persist there across every project, so you don’t re-authenticate per box.
  • devcontainer — the fallback for non-Guix hosts (Arch, macOS, …); see Dev Containers.

Force one with --runtime=guix|devcontainer|auto.

The runtime also determines how the server is bound, so the container can actually reach it — you don’t configure this per platform:

  • guix shares the host network namespace, so a loopback bind (127.0.0.1:8717) is reachable from inside the container. up leaves it as configured — no LAN exposure.
  • devcontainer runs on a bridge network, where the container reaches the host via the bridge gateway, not the host’s loopback. So up rebinds a loopback mcp.listen to 0.0.0.0 (same port) and, since a non-loopback bind is reachable off-host, auto-generates an MCP auth token (defaulting mcp.auth_token_file if unset). The container’s harness config is wired to the detected gateway with that token. A direct wraptool serve on a non-loopback bind requires mcp.auth_token_file — it will not start unauthenticated.

Host CLI credentials never enter the container either way — the isolation is the mount/user namespace (your normal $HOME, SSH keys, kubeconfig, and cloud tokens are not mounted). git is available inside for local work (status/diff/commit/branch), but anything needing credentials (push, private fetch) goes through wraptool’s policed MCP git tool on the host. The Guix runtime intentionally shares the harness pool and host network as described below. See design/guix-container-runtime.md.

Guix: convenience versus isolation

The Guix defaults deliberately retain three conveniences that weaken isolation. They avoid a separate environment definition, repeated harness setup, and a Docker Compose stack for every local service. Understand these boundaries before running untrusted projects.

Approve executable Guix inputs with direnv

manifest.scm and channels.scm are executable Scheme, not inert lock files. Guix evaluates them on the host while constructing the environment. A container can modify the writable worktree, including manifest.scm, so use direnv’s required-file approval before use guix:

require_allowed manifest.scm
if [[ -f channels.scm ]]; then
  require_allowed channels.scm
fi

use guix

require_allowed watches the files and records their content as part of direnv allow. If either changes, direnv refuses to run use guix again until you review the change and explicitly run:

direnv allow

This adds no prompt when the files are unchanged and one normal direnv approval after an intentional edit. It protects the direnv workflow only: direct guix shell -m manifest.scm, automation, and the current implementation of wraptool up do not consult direnv’s approval database. Use a direnv release that provides require_allowed; do not replace it with an unconditional custom use_guix wrapper.

Host localhost is reachable

The Guix runtime uses --network, which shares the host network namespace. Processes in the container can therefore connect to services bound only to host loopback, including local PostgreSQL, Redis, MongoDB, and test HTTP servers. This is intentional: those services can be reused directly without constructing a per-project Docker Compose environment.

Loopback is consequently a reachability boundary, not an authorization boundary. Assume untrusted project code can probe and use every host localhost service:

  • Require authentication where the service supports it.
  • Do not keep production data or privileged unauthenticated admin endpoints on ports reachable during a session.
  • Use separate test databases and least-privilege test accounts.
  • Keep wraptool’s admin UI authenticated; its token is intentionally not passed to the container.

Harness HOME is shared and writable

Every project receives the same writable harness HOME from $XDG_DATA_HOME/wraptool/harness-pool/home. This is also intentional: harness login, configuration, history, and skills are maintained once and persist across projects. Harness binaries and libraries are mounted read-only, but HOME state is read-write.

The consequence is that all projects using the pool form one harness-state trust domain. A compromised project can read or alter that state and leave persistent configuration for later projects. Keep unrelated credentials out of the pool, review shared skills/configuration before using high-risk repositories, and remove or recreate the pool when you need a clean trust boundary. Choose a separate XDG data directory or OS account when projects must not share harness state.

The shell opens automatically when you run up from a terminal. Control it with --shell:

wraptool up --shell=always   # always open a shell
wraptool up --shell=never    # just bring the container up (e.g. in scripts/CI)

One shared server can serve multiple projects and worktrees. The generated connection sends each host path as ?cwd=, but the client controls that value; configure mcp.allowed_cwd_roots when the server must enforce a path boundary.

Shut the project’s container down again with the counterpart command:

wraptool down          # stop this worktree's dev container
wraptool down --rm     # ...and remove it (next `up` recreates it fresh)

Manage the shared server (left running by down, since it serves other projects):

wraptool server status
wraptool server stop

Wiring the container: the wraptool feature

The container side is handled by the single wraptool dev container feature, published to the Forgejo OCI registry. Keyed on one harness choice, it does both jobs via toggles that default to true: provision installs the harness binary into the image, and connect writes the MCP client config that points it at the host wraptool. It layers onto any base image.

Rather than hand-write the .devcontainer/devcontainer.json, scaffold it:

wraptool init devcontainer --harness claude --lang go,java

That writes the committed config from the base:ubuntu + language-features model: the languages you list, the wraptool feature with your harness, the WRAPTOOL_* env, and the shared-pool mount — all prefilled. npm harnesses (claude/opencode/pi) auto-pull the node feature so the build won’t fail for lack of npm. It writes a fresh file and refuses to clobber an existing one (pass --force); edit the result freely. The emitted config looks like:

{
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
  "features": {
    "ghcr.io/devcontainers/features/go:1": {},
    "ghcr.io/devcontainers/features/node:1": {}, // for the npm harness
    "forge.snamellit.com/pti/wraptool/wraptool:0": { "harness": "claude" }
  },
  // Host workspace path (so the server scopes tools to this worktree).
  "containerEnv": { "WRAPTOOL_CWD": "${localWorkspaceFolder}" },
  // Token is a secret → remoteEnv keeps it out of image layers.
  "remoteEnv": { "WRAPTOOL_TOKEN": "${localEnv:WRAPTOOL_TOKEN}" },
  // Share the host harness-state pool (login/skills once, every project).
  // Object form so a source path with a space (macOS ~/Library/Application
  // Support) is never mis-split by the mount-string parser.
  "mounts": [
    { "source": "${localEnv:HOME}/.local/share/wraptool/harness-pool/home", "target": "/home/wraptool-harness", "type": "bind" }
  ]
}

init devcontainer writes the mount source for your host OS — the .local/share above is the Linux form; on macOS it is ${localEnv:HOME}/Library/Application Support/wraptool/harness-pool/home, on Windows ${localEnv:HOME}/AppData/Local/wraptool/harness-pool/home (forward slashes on every OS). It matches where wraptool up creates the pool, so the bind never targets a missing directory.

At container create the feature writes the MCP client config pointing at the host wraptool, auto-detecting the host as the container’s default-route gateway — so it works across docker networks and worktrees with no host.docker.internal or --add-host. See Dev Containers for every option. The older wraptool-connect feature (wiring only) is deprecated but stays published for existing configs.

Important

For container use the server must listen where the container can reach it — bind the docker gateway or 0.0.0.0 (e.g. listen: 0.0.0.0:8717), not 127.0.0.1. See Isolated environments.

Back to top