LAN-only HTTP service that runs claude -p subprocess on behalf of Sulkta apps. Bearer token + IP allowlist gated.
Find a file
Kayos 70f4dcc2a4 clients/c: apply audit findings — security + CVE bump (a69e924 → new)
HIGH:
- H1: enlarge test base_with_slash buffer 64 → 80; cmake --build now
  clean under -Werror=format-truncation.
- H2: CURLOPT_FOLLOWLOCATION = 0 (no cross-host bearer leak; SDK talks
  to a known endpoint, redirects unexpected). MAXREDIRS dropped.
- H3: cf_admin_revoke_token validates name [A-Za-z0-9_-]+ client-side
  before URL build; rejects "a/../healthz" with CF_ERR_USAGE before
  the request leaves the process.

MEDIUM:
- M1: cf_buf_append overflow guards — n + len + 1 wrap-check up front;
  newcap *= 2 doubling-loop bounded by SIZE_MAX/2.
- M2: 64 MiB CF_MAX_RESPONSE_BYTES cap exposed on the public header;
  write_cb aborts the transfer once exceeded → CF_ERR_TRANSPORT.
- M3: CURLOPT_CONNECTTIMEOUT_MS = 10000 (was implicit 300s default).
- M4: g_curl_init_count is now _Atomic int (C11 stdatomic) using
  atomic_fetch_add/sub; concurrent cf_client_new/cf_client_free across
  threads no longer races the libcurl global init/cleanup transition.

LOW:
- L1: push_auth propagates CF_ERR_OOM via an out-param instead of
  silently dropping the Authorization header (which previously surfaced
  as a misleading 401 from the server).
- L2: write_cb size*nmemb overflow defensive guard.

CVE:
- Bump vendored cJSON 1.7.15 → 1.7.18 (fixes CVE-2024-31755:
  cJSON_SetValuestring NULL-deref). cJSON.c/cJSON.h replaced from
  upstream tag v1.7.18; LICENSE file unchanged. README updated.

Tests added (15 → 21):
- test_revoke_token_validates_name: path-traversal name rejected,
  valid name proceeds through to transport.
- test_buf_append_overflow_guards: synthetic SIZE_MAX-edge inputs
  trigger error-return rather than wrap.
- test_response_body_size_cap: mock streams 65 MiB; client aborts
  with CF_ERR_TRANSPORT.
- test_connect_timeout: dial 10.255.255.1, assert <18s wallclock
  (vs. libcurl's 300s default).
- test_concurrent_client_init: 4 pthreads × 50 iters, no crash, no
  leak under valgrind.
- test_cjson_bump: cJSON_SetValuestring(node, NULL) returns NULL
  safely; malformed cJSON_Parse returns NULL.

Verification:
- cmake --build build (Release): clean
- ctest --test-dir build: 21/21 pass (incl. 10s connect-timeout test)
- ctest --test-dir build-asan (ASan + UBSan): clean
- valgrind --leak-check=full: 10,313 allocs == 10,313 frees, 0 errors,
  0 leaks

README updated: cJSON 1.7.18 note, C11 + stdatomic requirement.

Audit: memory/clawdforge-audits/c-a69e924.md
2026-04-28 23:25:22 -07:00
clawdforge runner: pipe prompts > 64KB via stdin to avoid OS argv limit 2026-04-28 22:08:47 -07:00
clients clients/c: apply audit findings — security + CVE bump (a69e924 → new) 2026-04-28 23:25:22 -07:00
.env.example v0.1 — clawdforge service scaffold 2026-04-28 16:46:44 -07:00
.gitignore clients/c: initial C SDK for clawdforge 2026-04-28 23:01:52 -07:00
compose.yml compose: pin project name to 'clawdforge' so it doesn't bleed into peer stacks 2026-04-28 17:10:39 -07:00
Dockerfile v0.1 — clawdforge service scaffold 2026-04-28 16:46:44 -07:00
LICENSE Initial commit 2026-04-28 16:43:19 -07:00
README.md v0.1 — clawdforge service scaffold 2026-04-28 16:46:44 -07:00
requirements.txt v0.1 — clawdforge service scaffold 2026-04-28 16:46:44 -07:00

clawdforge

LAN-only HTTP service that runs claude -p subprocess calls on behalf of Sulkta apps. One container holds the Claude Code subscription auth; multiple apps consume via bearer tokens + IP allowlist.

Why

  • Auth in one place — only this container needs to be claude /login'd, not every app
  • Smaller app images — apps stay tiny Python/Go containers, no node/npm/claude-cli
  • Audit log — every prompt + response chars + duration in one SQLite db
  • Reusable bone — petalparse, cauldron, johnny5 all consume the same surface

Surface

GET    /healthz                  liveness + claude --version smoke
POST   /run                      run a prompt, return parsed result
POST   /files                    upload a file, get a file_token to pass to /run
POST   /admin/tokens             mint a per-app token (admin)
GET    /admin/tokens             list app tokens (admin)
DELETE /admin/tokens/<name>      revoke a token (admin)

POST /run

{
  "prompt": "Sterilize this ingredient line: 'about 2 cups of cooked white rice'",
  "model": "sonnet",
  "system": "You are a precise recipe parser. Always reply with valid JSON.",
  "files": ["ff_..."],
  "timeout_secs": 60
}

Returns:

{
  "ok": true,
  "result": { "qty": 2, "unit": "cup", "food": "rice", "note": "cooked, white", "approx": true },
  "duration_ms": 4321,
  "stop_reason": "end_turn"
}

result is the inner {"type":"result","result":"..."} from claude -p --output-format json, auto-stripped of code fences and JSON-parsed if possible. If the inner is not valid JSON, it's returned as a string.

POST /files

multipart/form-data, field file, optional ttl_secs (60..86400, default 3600). Returns {"file_token": "ff_...", "ttl_secs": 3600, "size": 12345}. Use that token in subsequent /run requests to attach the file via claude -p --files.

Auth

Two layers:

  1. IP allowlist — global CIDR list in ALLOW_CIDRS env. Loopback always allowed. Per-app allowlist optional on top (mint with ip_cidrs: [...]).
  2. Bearer tokenAuthorization: Bearer cf_<...> for /run and /files, Authorization: Bearer <ADMIN_BOOTSTRAP_TOKEN> for /admin/*.

Tokens are SHA-256 hashed in SQLite. The plaintext is shown ONCE at create time.

Deploy

  1. SSH to Lucy: ssh lucy
  2. mkdir -p /mnt/user/appdata/clawdforge/{data,claude-config,claude-alt-config}
  3. Drop .env at /mnt/cache/appdata/secrets/clawdforge.env (chmod 600, root:root) — see .env.example
  4. Clone the repo to /opt/stacks/clawdforge (Lucy uses Gitea reverse-tunnel pattern)
  5. cd /opt/stacks/clawdforge && docker compose up -d --build
  6. Auth Claude CLI (one-time, persists on volume):
    docker exec -it clawdforge claude /login
    
    Walk through the device-auth flow. Credentials persist at /root/.claude/ inside the container, mapped to /mnt/user/appdata/clawdforge/claude-config/ on host.
  7. Smoke:
    curl http://192.168.0.5:8800/healthz
    
    Should report claude_present: true + a version string.
  8. Mint a token for the first consumer:
    curl -sS -X POST http://192.168.0.5:8800/admin/tokens \
      -H "Authorization: Bearer $ADMIN_BOOTSTRAP_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"name":"cauldron","ip_cidrs":["172.24.0.0/16"]}'
    
    Save the returned token into the consumer's env.

Client snippet (Python)

import os, requests

CF = "http://192.168.0.5:8800"
TOKEN = os.environ["CLAWDFORGE_TOKEN"]

r = requests.post(
    f"{CF}/run",
    headers={"Authorization": f"Bearer {TOKEN}"},
    json={
        "prompt": 'Reply with JSON: {"hello": "world"}',
        "model": "sonnet",
        "timeout_secs": 30,
    },
    timeout=60,
)
r.raise_for_status()
print(r.json()["result"])  # {'hello': 'world'}

Notes

  • The CLI is @anthropic-ai/claude-code (not the Python anthropic SDK).
  • Default model is sonnet; per-request override via model field.
  • Per-run working directory is staged under RUNS_DIR and torn down on exit, so claude can't pollute the container's working tree.
  • File uploads are scoped to the uploading app — token A can't reference token B's files.