Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.git
.github
.claude
.pi
.env
build
dist
tmp
docs
examples
*.md
56 changes: 56 additions & 0 deletions .github/workflows/image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Image

on:
push:
tags:
- "v*"
pull_request:
branches: [main]
paths:
- Dockerfile
- .dockerignore
- .github/workflows/image.yml

permissions:
contents: read
packages: write

env:
IMAGE: ghcr.io/${{ github.repository }}

jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0

- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

- if: github.event_name == 'push'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.IMAGE }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}

- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
52 changes: 52 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1
#
# Minimal claw-wrap image: the daemon for a sidecar container, and the source
# of the client binary for the agent container.
#
# docker build -t claw-wrap .
#
# Add the tools you want to broker in a derived image:
#
# FROM ghcr.io/dedene/claw-wrap:<version>
# COPY --from=<build-stage> /out/mytool /usr/local/bin/mytool
#
# See docs/KUBERNETES.md.

ARG GO_VERSION=1.26
ARG DEBIAN_RELEASE=trixie

FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-${DEBIAN_RELEASE} AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
ARG TARGETOS
ARG TARGETARCH
ARG VERSION=dev
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/claw-wrap ./cmd/claw-wrap

FROM debian:${DEBIAN_RELEASE}-slim

# tini runs as PID 1 so detached daemons started by wrapped tools (which get
# reparented to PID 1 when their parent exits) are reaped instead of lingering
# as zombies.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tini \
&& rm -rf /var/lib/apt/lists/*

# A real passwd entry gives wrapped tools a sane HOME and USER.
ARG UID=10001
ARG GID=10001
RUN groupadd --gid ${GID} claw \
&& useradd --uid ${UID} --gid ${GID} --home-dir /var/lib/claw-wrap --create-home --shell /usr/sbin/nologin claw \
&& install -d -o claw -g claw -m 0700 /run/openclaw

COPY --from=build /out/claw-wrap /usr/local/bin/claw-wrap

USER ${UID}:${GID}
WORKDIR /var/lib/claw-wrap
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/claw-wrap"]
CMD ["daemon"]
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ claw-wrap supports two approaches for credential injection:
| Backend | Prefix | Example | Notes |
| ------- | ------ | ------- | ----- |
| [pass](https://www.passwordstore.org/) | `pass:` | `pass:cli/github/token` | Default when no prefix given |
| Environment | `env:` | `env:MY_TOKEN` | Reads from daemon environment |
| Environment file | `env:` | `env:MY_TOKEN` | Reads `KEY=value` lines from `/run/openclaw/env` |
| Secret file | `file:` | `file:/etc/claw-wrap/secrets/token` | Docker/Kubernetes secret mounts; re-read on every use |
| [1Password](https://1password.com/) | `op://` | `op://Vault/Item/field` | Requires `op` CLI, session auth |
| [Bitwarden](https://bitwarden.com/) | `bw:` | `bw:item-uuid` | Requires `bw` CLI, session managed |
| [macOS Keychain](https://support.apple.com/guide/keychain-access/) | `keychain:` | `keychain:service-name` | macOS only |
Expand Down Expand Up @@ -341,20 +342,26 @@ claw-wrap works with deny-by-default sandboxes where credentials directories (`~
See [docs/SANDBOX.md](docs/SANDBOX.md) for the full guide — firejail profile, nono setup,
self-restart mechanism, and verification steps.

## Containers and Kubernetes

claw-wrap can run as a sidecar: the agent container gets only the client binary, and the sidecar holds the credentials and runs the real tools. The repository `Dockerfile` builds a small base image to extend with your tools. See [docs/KUBERNETES.md](docs/KUBERNETES.md) for the pod layout, security model and a tested manifest.

## Documentation

- [Installation Guide](docs/INSTALL.md) — full setup with `pass`, systemd, and troubleshooting
- [Configuration Reference](docs/CONFIG.md) — all options for credentials, tools, blocked/allowed
args, output redaction, config file injection
- [HTTP Proxy Setup](docs/CONFIG.md#http-proxy-settings) — MITM proxy for API credential injection
- [Sandbox Setup](docs/SANDBOX.md) — firejail (Linux) and nono (macOS) with verification steps
- [Kubernetes Sidecar](docs/KUBERNETES.md) — shared socket volume, UID/GID setup, Secrets, probes, audit
- [Protocol Specification](docs/SPEC.md) — HMAC authentication, message framing, proxy protocol

## Usage

```bash
# Daemon mode (usually via systemd)
claw-wrap daemon
claw-wrap daemon --uid 1000 --runtime-gid 2000 --socket-mode 0660 --auth-mode 0640 # shared with another UID

# Admin commands
claw-wrap list # List configured tools
Expand Down
43 changes: 37 additions & 6 deletions cmd/claw-wrap/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"

"claw-wrap/internal/config"
"claw-wrap/internal/daemon"
Expand Down Expand Up @@ -99,21 +100,51 @@ func runDaemon() error {
opts = append(opts, daemon.WithConfigPath(os.Args[i+1]))
i++
case arg == "--uid" && i+1 < len(os.Args):
var uid uint32
fmt.Sscanf(os.Args[i+1], "%d", &uid)
uidValue, err := strconv.ParseUint(os.Args[i+1], 10, 32)
if err != nil {
return fmt.Errorf("invalid --uid value %q: %w", os.Args[i+1], err)
}
uid := uint32(uidValue)
opts = append(opts, daemon.WithAllowedUID(uid))
i++
case arg == "--runtime-gid" && i+1 < len(os.Args):
gid, err := strconv.Atoi(os.Args[i+1])
if err != nil || gid < 0 {
return fmt.Errorf("invalid --runtime-gid value %q (must be >= 0)", os.Args[i+1])
}
opts = append(opts, daemon.WithRuntimeGID(gid))
i++
case arg == "--auth-mode" && i+1 < len(os.Args):
mode, err := daemon.ParseAuthFileMode(os.Args[i+1])
if err != nil {
return fmt.Errorf("invalid --auth-mode: %w", err)
}
opts = append(opts, daemon.WithAuthFileMode(mode))
i++
case arg == "--socket-mode" && i+1 < len(os.Args):
mode, err := daemon.ParseSocketFileMode(os.Args[i+1])
if err != nil {
return fmt.Errorf("invalid --socket-mode: %w", err)
}
opts = append(opts, daemon.WithSocketFileMode(mode))
i++
case arg == "-h" || arg == "--help":
fmt.Printf(`claw-wrap daemon - Start the secrets daemon

Usage:
claw-wrap daemon [options]

Options:
--socket PATH Socket path (default: %s)
--config PATH Config path (default: %s)
--uid UID Allowed UID (default: %d)
-h, --help Show this help
--socket PATH Socket path (default: %s)
--config PATH Config path (default: %s)
--uid UID Allowed UID (default: %d)
--runtime-gid GID Shared group for runtime dir, socket and auth file (optional)
--auth-mode MODE Auth file mode: 0600 or 0640 (default: 0600)
--socket-mode MODE Socket mode: 0600 or 0660 (default: 0600)
-h, --help Show this help

Environment:
CLAW_WRAP_RUNTIME_DIR Runtime dir for socket and auth file (set it for the wrapper too)
`, paths.SocketPath(), paths.ConfigPath(), os.Getuid())
return nil
}
Expand Down
48 changes: 47 additions & 1 deletion cmd/claw-wrap/main_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package main

import "testing"
import (
"os"
"strings"
"testing"
)

func TestSafeToolNameRegex(t *testing.T) {
valid := []string{"gh", "my-tool", "tool.v2", "tool_name"}
Expand All @@ -17,3 +21,45 @@ func TestSafeToolNameRegex(t *testing.T) {
}
}
}

func TestRunDaemon_InvalidFlags(t *testing.T) {
t.Run("invalid uid", func(t *testing.T) {
orig := os.Args
defer func() { os.Args = orig }()
os.Args = []string{"claw-wrap", "daemon", "--uid", "nope"}
err := runDaemon()
if err == nil || !strings.Contains(err.Error(), "invalid --uid") {
t.Fatalf("runDaemon() error = %v, want invalid --uid", err)
}
})

t.Run("invalid auth mode", func(t *testing.T) {
orig := os.Args
defer func() { os.Args = orig }()
os.Args = []string{"claw-wrap", "daemon", "--auth-mode", "0666"}
err := runDaemon()
if err == nil || !strings.Contains(err.Error(), "invalid --auth-mode") {
t.Fatalf("runDaemon() error = %v, want invalid --auth-mode", err)
}
})

t.Run("invalid socket mode", func(t *testing.T) {
orig := os.Args
defer func() { os.Args = orig }()
os.Args = []string{"claw-wrap", "daemon", "--socket-mode", "0640"}
err := runDaemon()
if err == nil || !strings.Contains(err.Error(), "invalid --socket-mode") {
t.Fatalf("runDaemon() error = %v, want invalid --socket-mode", err)
}
})

t.Run("invalid runtime gid", func(t *testing.T) {
orig := os.Args
defer func() { os.Args = orig }()
os.Args = []string{"claw-wrap", "daemon", "--runtime-gid", "-1"}
err := runDaemon()
if err == nil || !strings.Contains(err.Error(), "invalid --runtime-gid") {
t.Fatalf("runDaemon() error = %v, want invalid --runtime-gid", err)
}
})
}
Loading
Loading