LAN-only HTTP service that runs claude -p subprocess on behalf of Sulkta apps. Bearer token + IP allowlist gated.
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 |
||
|---|---|---|
| clawdforge | ||
| clients | ||
| .env.example | ||
| .gitignore | ||
| compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| README.md | ||
| requirements.txt | ||
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:
- IP allowlist — global CIDR list in
ALLOW_CIDRSenv. Loopback always allowed. Per-app allowlist optional on top (mint withip_cidrs: [...]). - Bearer token —
Authorization: Bearer cf_<...>for/runand/files,Authorization: Bearer <ADMIN_BOOTSTRAP_TOKEN>for/admin/*.
Tokens are SHA-256 hashed in SQLite. The plaintext is shown ONCE at create time.
Deploy
- SSH to Lucy:
ssh lucy mkdir -p /mnt/user/appdata/clawdforge/{data,claude-config,claude-alt-config}- Drop
.envat/mnt/cache/appdata/secrets/clawdforge.env(chmod 600, root:root) — see.env.example - Clone the repo to
/opt/stacks/clawdforge(Lucy uses Gitea reverse-tunnel pattern) cd /opt/stacks/clawdforge && docker compose up -d --build- Auth Claude CLI (one-time, persists on volume):
Walk through the device-auth flow. Credentials persist atdocker exec -it clawdforge claude /login/root/.claude/inside the container, mapped to/mnt/user/appdata/clawdforge/claude-config/on host. - Smoke:
Should reportcurl http://192.168.0.5:8800/healthzclaude_present: true+ a version string. - Mint a token for the first consumer:
Save the returnedcurl -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"]}'tokeninto 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 PythonanthropicSDK). - Default model is
sonnet; per-request override viamodelfield. - Per-run working directory is staged under
RUNS_DIRand torn down on exit, soclaudecan't pollute the container's working tree. - File uploads are scoped to the uploading app — token A can't reference token B's files.