CL
Clausgap
/Docs

Documentation

Everything you need to deploy Clausgap, write policies, and integrate your AI agents.

How Clausgap works

Clausgap sits between your AI agents and every tool or API they touch. It intercepts each request, evaluates it against your policy bundle, and either allows it, denies it, rate-limits it, or holds it in a multi-party escrow queue pending human approval.

On-prem / Docker

Proxy

clausgap-proxy — a single binary supporting HTTP, PostgreSQL, and MCP. Deploy one container; configure which protocols you need via a listeners array.

Cloud

Control Plane

REST API for managing policies, escrow holds, events, and subscriptions. Pushes signed policy bundles to proxies.

Browser

Dashboard

Web UI for writing policies, voting on escrow holds, and inspecting the BLAKE3-chained forensic audit trail.

text
Agent  (SDK: X-Clausgap-Context HMAC header)
       (Keyless: JWT Bearer / API key / IP-CIDR — no code changes)
  │
  ▼
clausgap-proxy ──┬──► :8443 HTTP listener  ──► REST APIs / tools
                 ├──► :5433 PG listener    ──► PostgreSQL / Redshift
                 └──► :3000 MCP listener   ──► MCP tool servers

  │  Local bundle evaluation (no network round-trip on hot path)
  │
  ├─ ALLOW  → forward request, return response
  ├─ DENY   → 403 / SQL error / JSON-RPC error
  ├─ ESCROW → 423 Locked + {escrow_id}  (agent polls; M-of-N voting)
  └─ RATE   → 429 Too Many Requests

  ↕  Async (bundle sync, event drain, escrow state)
Control Plane API  (/v1/...)

Policy evaluation happens inside the proxy using a locally cached, Ed25519-signed bundle — so there is no control-plane round-trip on the hot path. Bundles are refreshed from the control plane every 30 seconds (configurable). Agent identity is resolved from the X-Clausgap-Context HMAC header (SDK mode) or, in keyless mode, from a JWT Bearer token, a registered API key, or source IP/CIDR — no agent code changes required.

Quickstart

Create a workspace, then four steps take you to a policy enforcing your agent's traffic. The Get started page in the dashboard tracks the same steps and completes each one by itself when it happens — a proxy connects, a request arrives, a policy decides one.

1

Create your workspace

Sign up at app.clausgap.dev/signup and open the link we email you. Creating the workspace shows your API key once and takes you to Get started.

⚠

The API key is only shown at creation time. Store it in a secret manager before leaving the page. You can issue more under API Keys.

The key looks like clk_free.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx. The proxy uses it as control_plane.api_key in proxy.yaml; for the API, pass it as Authorization: Bearer <key>.

ℹ

A new workspace starts in Discovery Mode for 7 days. While it is on, an agent that has no policy yet is let through and what it does is recorded, so nothing breaks on day one and your first policy can be drafted from real traffic. It never overrides a policy that exists, and a request that does not identify its agent is still denied. When it ends, an agent with no policy is denied. Get started shows the end date, and lets you extend it or turn it off; admins are emailed 48 hours before it ends if nothing is enforcing yet.

2

Connect a proxy

The proxy runs in your network, in front of the APIs and tools your agent calls. The fastest path is the Deploy Proxy wizard — it generates routes.yaml, proxy.yaml, Docker Compose and Helm values pre-filled with your tenant ID and a fresh HMAC secret. Or write them yourself:

routes.yaml maps tool names to upstream URLs. The tool name is what your policy rules reference.

routes.yaml
yaml
routes:
  # HTTP upstream — proxied as-is
  - tool: "web_search"
    upstream: "http://search-service:8080"
    methods: ["GET"]

  - tool: "file_write"
    upstream: "http://fs-service:9000"
    methods: ["POST", "PUT", "DELETE"]

  # MCP / JSON-RPC server — tools auto-discovered on startup
  - tool: "mcp_tools"
    upstream: "http://mcp-server:5000/"
    methods: ["POST"]
    protocol: "jsonrpc"
    discover: true

proxy.yaml configures the proxy. Values prefixed with $ are expanded from the environment.

proxy.yaml
yaml
tenant_id: "$CLAUSGAP_TENANT_ID"
log:
  level: "info"

control_plane:
  url:          "https://api.clausgap.dev"
  api_key:      "$CLAUSGAP_API_KEY"
  poll_interval: "30s"               # How often to pull policy bundle

listeners:
  # HTTP listener — governs REST API / tool calls
  - protocol: http
    addr: ":8443"
    # advertise_addr: "agents.example.com:443"   # Where agents connect, if not addr (see below)
    http:
      routes_file: "./routes.yaml"
      hmac_secret: "$HMAC_SECRET"    # Shared with your agent SDK

  # Uncomment to add PostgreSQL governance:
  # - protocol: pg
  #   addr: ":5433"
  #   pg:
  #     upstream_host:   "postgres"
  #     upstream_port:   5432
  #     upstream_dbname: "mydb"
bash
# Run the binary
HMAC_SECRET=my-secret CLAUSGAP_API_KEY=clk_free.xxx \
  clausgap-proxy -config proxy.yaml

# Or with Docker
docker run -d --name clausgap-proxy \
  -p 8443:8443 \
  -e HMAC_SECRET=my-secret \
  -e CLAUSGAP_API_KEY=clk_free.xxx \
  -e CLAUSGAP_TENANT_ID=my-org \
  -v $(pwd)/routes.yaml:/etc/clausgap/routes.yaml \
  -v $(pwd)/proxy.yaml:/etc/clausgap/proxy.yaml \
  ghcr.io/clausgap/clausgap-proxy:latest
ℹ

Private beta. The proxy image and binary aren't publicly downloadable yet. Access comes with onboarding — email hello@clausgap.dev.

The image runs as an unprivileged user (uid 65532), not root. Mounted config only needs to be readable by it; the bundle cache and event spool default to /tmp. If you set bundle_cache_path or event_spool_dir to a volume, make it writable by uid 65532. On Kubernetes it runs under the restricted Pod Security standard:

yaml
securityContext:
  runAsNonRoot: true
  runAsUser: 65532
  runAsGroup: 65532
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true   # with an emptyDir mounted at /tmp
  capabilities: { drop: ["ALL"] }
  seccompProfile: { type: RuntimeDefault }

Check liveness: GET http://localhost:8443/healthz returns {"status":"ok","proxy_id":"<id>"} right away. GET /readyz returns 503 {"status":"not_ready","reason":"no policies loaded"} until the proxy has fetched its first bundle, then 200 {"status":"ready","policies":<count>}.

If agents reach the proxy on a different address (a Docker port mapping, a Kubernetes Service, a load balancer), the proxy can't see it: addr is only where the binary listens. Set advertise_addr on the listener to the address agents use, and the Proxies page shows it as Agents connect to. It is a label only and changes nothing about how the proxy listens or decides. To confirm an address reaches the proxy you expect, call /healthz on it from where your agents run and compare proxy_id with the ID on the Proxies page.

This step completes when the proxy's first heartbeat arrives, within about 30 seconds of it starting. It then appears on the Proxies page.

3

Send your agent's traffic through it

Point your agent at the proxy's address in place of the API it calls today. The proxy needs to know which agent is calling. The fastest way is keyless mode — the proxy resolves identity from the Authorization header or source IP, with no agent code changes (see Keyless mode). For verified identity and confidence-level policies, add the X-Clausgap-Context HMAC-signed header — the SDK does it for you, or sign it yourself as below (each header covers one request and works once). See Context Signing for the full specification.

python
import base64, hashlib, hmac, json, secrets, time, requests

HMAC_SECRET = "my-secret"   # matches proxy.yaml hmac_secret
PROXY_URL   = "http://localhost:8443"

def make_context(method, target, body, agent_role, agent_id, session_id, confidence=1.0):
    """A v2 context header, signed for exactly this request and usable once."""
    payload = json.dumps({
        "agent_role": agent_role,
        "agent_id":   agent_id,
        "session_id": session_id,
        "confidence": confidence,
        "step_index": 0,
        "model_id":   "gpt-4",
    }).encode()
    b64   = base64.b64encode(payload).decode()
    ts    = str(int(time.time()))
    nonce = secrets.token_hex(16)
    message = "\n".join([
        "clausgap-context-v2", ts, nonce, method.upper(), target,
        hashlib.sha256(body).hexdigest(), b64,
    ])
    sig = hmac.new(HMAC_SECRET.encode(), message.encode(), hashlib.sha256).hexdigest()
    return f"v2.{b64}.{sig}.{ts}.{nonce}"

# Sign the exact path + query you send — the header is only valid for it.
target = "/web_search?q=latest+sales+report"
resp = requests.get(
    PROXY_URL + target,
    headers={"X-Clausgap-Context": make_context("GET", target, b"", "my_agent", "a-1", "sess-001", 0.95)},
)

if resp.status_code == 200:
    print("Allowed:", resp.json())
elif resp.status_code == 403:
    print("Denied by policy")
elif resp.status_code == 423:
    escrow_id = resp.json()["escrow_id"]
    print("Held in escrow — awaiting M-of-N approval:", escrow_id)

While Discovery Mode is on, a request from an agent with no policy is forwarded and recorded as observed, not denied, when a credential fixes the agent's role (an agent key, a token, an IP mapping or a role-bound signing key). In direct mode (a framework integration) that applies to agents using their own agent key, whose role the key fixes. This step completes on the first request, and you can watch them arrive on the Decisions page.

4

Review the policy drafted from your traffic

Once an agent has been observed, the Policies page lists its role with what it called and offers Generate draft. The draft has one allow rule for each tool and method the agent was seen using, and nothing more. Review it and edit it like any other policy: tighten a rule, add a deny, or hold a risky action for approval.

You can also write a policy from scratch, in the dashboard or with POST /v1/policies. The agent_role must match the role your agent is identified as.

my_policy.clausgap
clausgap
policy "my_policy" {
    description = "Describe what this policy controls"

    agent_role  = "my_agent"
    environment = "staging"

    allow call tool "read_data" when method = "GET"
    allow call tool "submit_action"
    deny  call tool "delete_records"

    # Hold dangerous actions for multi-party approval
    escrow when tool = "submit_action" {
        hold_type = "multi_party"
        required_approvals = 2
        notify = ["https://hooks.slack.com/services/T000/B000/approvals"]
    }

    # Hold low-confidence actions for review
    escrow when confidence < 0.7
    rate_limit 10 requests per minute
}
5

Turn enforcement on

A draft changes nothing until it is live. Open it and choose Deploy to proxy, or Submit for review if your workspace requires a second person to approve policy changes. The control plane compiles it and pushes a signed bundle to your proxy within one poll interval (default 30 s).

From then on, that agent's requests are decided by your policy: allowed, denied (403), or held for approval (423). The step completes on the first request a policy decides. Each decision is hashed, chained and stored as a forensic record.

Agents you haven't written a policy for keep passing through until Discovery Mode ends. Cover each one before then, or extend the window from Get started.

From here: give each agent its own credential (Connecting your identity provider), hold risky actions for approval (Escrow rules), and export evidence an auditor can check offline (Verifying evidence).

Proxy configuration reference

The unified proxy (clausgap-proxy) is configured via a single YAML file. A listeners: array activates one or more protocol listeners sharing a single policy engine and control-plane connection. All string values support $ENV_VAR substitution. See Protocol Reference for per-listener configuration fields.

FieldTypeDescription
tenant_id*stringYour workspace tenant ID
proxy_idstringIdentifier for this proxy instance. Default: hostname
log.levelstringNested under a top-level log: key. "debug", "info", "warn", "error". Default: "info"
listeners*[]ListenerArray of protocol listeners. Each entry has protocol (http | pg | mcp) and addr fields plus a protocol-specific sub-block. Each sub-block also accepts an optional tls: block (cert_file, key_file, client_ca_file for mTLS, min_version) — see Protocol Reference.
bundle.pathstringLocal bundle file path (offline / air-gapped mode)
bundle.public_key_pathstringEd25519 public key for bundle signature verification
control_plane.urlstringControl plane base URL
control_plane.api_keystringAPI key used to fetch bundles and drain events
control_plane.poll_intervaldurationBundle fetch interval. Default: "30s"
control_plane.bundle_cache_pathstringWhere the last fetched bundle and the bundle-signing key are saved. A proxy restarted while the control plane is unreachable starts on them (still signature-, pin- and tenant-checked). Default: the temporary directory; set a volume path for it to survive a container being replaced.
control_plane.event_spool_dirstringWhere decision events wait when the control plane doesn't accept them, until it does; they are sent first, in order. At most 5,000 batches (oldest dropped first). Default: next to the bundle cache.
control_plane.public_key_sha256stringPins the bundle-signing public key the proxy fetches from the control plane (hex SHA-256, logged by clausgap-control at startup). With a pin, a substituted key is refused over any connection; without one, the key is only fetched over https or from a private-network host.
trusted_proxies[]stringCIDRs of load balancers / reverse proxies in front of the proxy. Only connections from these may set the client address — needed behind a load balancer (e.g. Fly) for IP/CIDR identity, allowed_cidrs and per-IP rate limits to see the real client.
client_ip_headerstringHeader those proxies set: "X-Forwarded-For" (default; the rightmost untrusted hop is the client) or a single-value header such as "Fly-Client-IP" or "X-Real-IP". Requires trusted_proxies.
scopes[]stringPolicy scopes this proxy subscribes to. Default: ["default"]. Add named scopes to receive only matching policies from the control plane.
metrics.addrstringPrometheus metrics bind address. Default: ":9090"

Health endpoints

/healthz and /readyz are served on the HTTP listener regardless of policy load state. /metrics is served only on the separate metrics server bound to metrics.addr (default :9090), never on the port agents use: it carries agent roles and tool names. Keep that port off public networks.

GET/healthz

Liveness check. Always returns 200 {"status":"ok"}.

GET/readyz

Readiness check. 200 {"status":"ready","policies":<count>} once at least one policy bundle has loaded; 503 {"status":"not_ready","reason":"no policies loaded"} before that.

GET/metrics

Prometheus metrics endpoint. On metrics.addr only.

Routes file

routes.yaml maps tool names (used in policy rules) to upstream services. The proxy routes each inbound request to the matching upstream after policy evaluation.

FieldTypeDescription
tool*stringName used in policy allow/deny/escrow rules
upstream*stringUpstream URL. The request path is appended.
methods*string[]Allowed HTTP methods, e.g. ["GET","POST"]
matchstring"" (default): the upstream's path and everything below it, on whole path segments — /docs covers /docs/x but not /docsadmin. "prefix": a raw string prefix (e.g. /mcp_ matches /mcp_search).
protocolstring"" for plain HTTP (default) or "jsonrpc" for MCP servers
discoverboolWhen true + protocol=jsonrpc, calls tools/list on startup and creates per-tool routes automatically
ℹ

Requests whose path contains a . or .. segment — in any spelling, including percent-encoded and double-encoded — are refused with 400 INVALID_PATH before any policy is evaluated, so a path like /docs/../admin can't be judged as one tool and served by the upstream as another.

ℹ

For MCP servers with discover: true, each discovered tool is registered by its bare tool name in policy rules, e.g. allow call tool "read_file".

Policy DSL

Policies are written in the Clausgap DSL — a human-readable, version-controlled configuration language. Each policy file contains one or more policy blocks.

Structure

clausgap
policy "policy_name" {
    description = "Human-readable description"

    # Required — must match the agent_role in X-Clausgap-Context
    agent_role  = "my_agent"

    # Optional — restrict to a specific environment
    environment = "prod"   # or "staging", "dev"

    # Optional — policy scope for multi-proxy deployments (default: "default")
    scope       = "finance"

    # ... rules ...
}

Tool rules (HTTP proxy)

clausgap
# Allow / deny by tool name
allow call tool "web_search"
deny  call tool "delete_records"

# Wildcard
allow call tool "*"

# Conditional — only allow GET requests to this tool
allow call tool "read_data" when method = "GET"

Table rules (PG proxy)

clausgap
# Column-level read control
allow read on table "orders"  columns ["id", "status", "total"]
deny  read on table "users"   columns ["ssn", "credit_card"]

# Blanket table control
allow read on table "products"
deny  write on all tables

# Row-level filter — injected as WHERE clause on every query
filter on table "orders" where "region = 'EMEA'"

Deterministic deny conditions (context_deny)

context_deny enforces hard denials on structured call metadata — SQL operations, table names, and time windows. These rules are deterministic: they do not depend on confidence scores or LLM output and are directly auditable in compliance reviews.

clausgap
# Deny destructive SQL operations
context_deny operation in (DELETE, DROP, TRUNCATE)

# Deny access to sensitive tables
context_deny table in ("users_pii", "payments", "audit_log")

# Deny outside business hours (UTC)
context_deny time_of_day outside "09:00-17:00"
ℹ

context_deny rules run before allow checks and cannot be overridden by any allow rule. They always produce a hard 403 with the matched condition in the audit record.

Escrow rules

Escrow holds the request (423 Locked) until one or more approvers vote, an external condition is met, or a time delay expires. Supports four hold types: manual, multi_party, conditional, and time_delayed.

clausgap
# Simple manual hold — one approver required
escrow when tool = "send_email"

# Multi-party — M-of-N approvers
escrow when tool = "submit_order" {
    hold_type          = "multi_party"
    required_approvals = 2
    notify             = ["https://hooks.slack.com/services/T.../B.../finance"]
}

# Conditional — released when external webhook confirms
escrow when tool = "deploy" {
    hold_type      = "conditional"
    condition      = "webhook_confirm"
    condition_url  = "https://ci.internal/check"
}

# Manual with custom TTL (seconds)
escrow when tool = "modify_record" {
    hold_type = "manual"
    ttl       = 86400
}

# Time-delayed — auto-releases after the delay unless cancelled
escrow when tool = "bulk_delete" {
    hold_type = "time_delayed"
    ttl       = 3600
}

Rate limiting and budgets

clausgap
rate_limit 20 requests per minute
rate_limit 100 requests per hour
rate_limit 1000 requests per day
rate_limit 50 requests per session

# Not enforced yet: these compile with a warning and have no effect
cost_budget  5.00    per session       # USD
token_budget 500000  tokens per hour

active_hours "08:00-20:00" timezone "Europe/London"

expires "2027-12-31T23:59:59Z"

Evaluation order

When a request arrives, the proxy evaluates rules in this fixed order. Evaluation short-circuits at the first match. A rate_limit is enforced separately: a call over its limit gets 429.

ℹ

Rate limits are shared: every proxy, every listener in it, and direct mode pool their counts through the control plane every couple of seconds, so an agent's limit holds however it reaches its tools. A burst split across paths can go over by what arrives in that time. Velocity limits and sequence rules are still counted by each proxy instance and separately in direct mode: run one proxy instance per tenant until they are shared too.

#CheckOutcome
1Role + environment matchFind policies for this agent
2Injection scanPattern matched in tool args → 403
3Sequence checkSuspicious tool sequence → 403
4context_deny checkStructured condition matched → 403 (hard, no override)
5Velocity checkCall-rate spike detected → 403
6Deny checkExplicit deny rule matched → 403 (short-circuit)
7Allow checkNo matching allow rule → 403 (default deny)
8Escrow checkEscrow condition met → 423 Locked (M-of-N voting)
9Column maskingDenied columns returned as NULL (PG proxy)

Injection Defense

Clausgap detects prompt injection and behavioural anomalies before your policy rules even run. All checks run inline, inside the proxy, and are configured in the same DSL as your allow/deny rules.

trigger_source — verify who initiated the call

Pass trigger_source in your SDK context to declare whether the tool call was initiated by a human user, another agent, or the environment (e.g. a retrieved document). Use it in when conditions to restrict sensitive tools to direct user intent only.

clausgap
# Only allow email when the user — not a retrieved doc — triggered it
allow call tool "send_email" when trigger_source = "user"
deny  call tool "send_email" when trigger_source = "environment"

In the SDK, set triggerSource on each evaluate call. The proxy defaults to "user" when the field is absent.

typescript
// TypeScript SDK
await gate.evaluate({
  toolName: "send_email",
  triggerSource: "user",   // "user" | "environment" | "agent"
})

injection_scan — block known attack patterns

Scans tool arguments against a built-in library of prompt injection signatures (instruction overrides, privilege escalation, role impersonation, exfiltration attempts), plus any custom patterns registered for your tenant. Fires before rule matching; a tool can opt out with except injection_scan (below), but no other allow rule bypasses it.

clausgap
injection_scan action = "deny"

# Opt a trusted tool out of scanning
allow call tool "internal_summariser" except injection_scan
ℹ

action must be "deny" — it is the only value the validator accepts. An injection match is always a hard 403.

ℹ

Custom patterns registered via POST /v1/admin/injection-patterns are compiled into the signed bundle and scanned alongside the built-in defaults — the built-in set is checked first, then your tenant's custom patterns.

sequence_deny — detect suspicious sequences

Triggers when a tool is called shortly after a specific preceding tool in the same session — a pattern common in exfiltration chains (read data, then immediately send it out). The within window is in seconds.

clausgap
# Deny any exfil-like tool called within 60 s of a DB read
sequence_deny tools ["send_email", "post_webhook"]
    after ["query_database", "read_file"]
    within 60

velocity_limit — spike detection

Flags sessions where the per-minute call rate spikes above a multiple of the session baseline. Unlike rate_limit (which enforces an absolute cap), velocity detection catches sudden bursts that are anomalous relative to normal behaviour.

clausgap
# Deny if current-minute rate is 5× the session baseline
# (baseline established after min 10 calls)
velocity_limit spike 5.0 min 10 action = "deny"
⚠

sequence_deny and velocity_limit both deny unconditionally when triggered today — there is no "escrow" action (only "deny" validates). Don't configure these expecting a human-review step — they hard-block the call.

Keyless mode (no SDK required)

Keyless mode lets you drop Clausgap in front of any existing agent framework — LangChain, AutoGen, CrewAI, raw tool loops — with zero code changes. The proxy resolves agent identity from the request itself using a priority resolver chain.

#ResolverHow it works
1HMAC (SDK)X-Clausgap-Context header — highest trust, verified identity
2JWT BearerRS256/ES256/EdDSA token validated against JWKS endpoint; the provider must have an audience
3API keyHMAC-SHA256 lookup against registered keyless API keys
4IP / CIDRStatic role mapping — lowest priority, no credential required

Enable keyless mode

yaml
# proxy.yaml
listeners:
  - protocol: http
    addr: ":8443"
    http:
      routes_file: "./routes.yaml"
      keyless:
        enabled: true
        mode: "strict"            # "permissive" allows anonymous fallback during migration
        api_key_sources:
          - header: "Authorization"
            prefix: "Bearer "
          - header: "X-API-Key"
        static_roles:
          - source_ip: "10.0.0.0/8"
            agent_role: "internal-agent"
            agent_id:   "internal-01"
        # Optional: enrich audit logs from advisory headers
        supplementary_headers:
          session_id: "X-Conversation-Id"
          agent_id:   "X-Agent-Instance-Id"
        # Optional: extract developer-defined confidence from response body
        confidence:
          source: "body_json"
          path:   "$.confidence"
          trust:  "unverified"   # advisory label: confidence came from an unsigned header/body
ℹ

In strict mode, requests with no matching resolver are rejected with 403. Set mode: permissive with a permissive_until deadline to allow an anonymous fallback during initial rollout — the deadline IS the acknowledgement; once it passes, the proxy automatically enforces strict mode. Anonymous callers still can't reach a tool: no policy may name the role anonymous, and Discovery Mode never lets them through. Without keyless identity configured, a request with no credential and no matching IP role is refused.

Identity trust tiers

Every decision record includes an identity_resolver field that indicates how the agent was identified. The dashboard surfaces this as a trust badge on each event.

ResolverTrust tierAudit label
hmacCryptographicSDK-signed context header — tamper-evident
jwtCryptographicRS256/ES256/EdDSA token, JWKS-verified
api_keyCryptographicHMAC-SHA256 registered credential
ip_cidrNetwork-inferredSource IP match — not a cryptographic assertion
anonymousUnauthenticatedNo resolver matched (permissive mode only)
⚠

IP/CIDR identity is network-path inference, not a cryptographic assertion. Treat it as convenience for trusted internal networks only — not for regulated or high-value actions. Behind a load balancer the connection comes from the balancer, so set trusted_proxies and client_ip_header (see Proxy configuration) or every agent appears to come from the same address.

JWT providers

Register a provider with POST /v1/keyless/jwt-providers. An audience is required — it must match the aud claim your issuer puts in tokens meant for Clausgap; without it, any token the issuer minted for another application would be accepted. ES256, ES384 and ES512 keys must be on the curve the algorithm names (P-256, P-384, P-521), and the jwks_uri must be a public https URL. claims_mapping names the claim that carries the agent's role (default clausgap_role) and ID (default sub) — tokens from your organisation's identity provider work as they are. Registering a provider with a name or issuer you already registered is a 409. Setting this up end to end — with single sign-on for people too — is covered in Connecting your identity provider.

What reaches your upstream

The proxy removes the agent's Clausgap credentials before forwarding — the X-Clausgap-Context header and whichever header carried a keyless JWT or API key (Authorization, X-API-Key) — so a tool server can never replay them as the agent. To give a tool its own credentials, set upstream_headers on the HTTP listener (see Protocol Reference).

Policy scopes

Scopes let large deployments partition policies across proxy fleets — each proxy receives only the policies it needs. Every policy belongs to one scope (default: "default"). Proxies declare which scopes they subscribe to; the control plane compiles and signs a filtered bundle containing only matching policies. Existing deployments are unaffected — everything continues to use the implicit default scope.

Assign a scope in the policy DSL

clausgap
policy "pii_guardrail" {
    description = "PII controls for the finance team agents"
    agent_role  = "finance_agent"
    scope       = "finance"   # ← assigns this policy to the "finance" scope

    deny call tool "export_data"
    context_deny table in (customers_pii, ssn_records)
}

The scope field is optional. Omitting it is equivalent to scope = "default".

Subscribe a proxy to scopes

Add a scopes: list to proxy.yaml. The proxy sends this list in every heartbeat and uses it when fetching the bundle from the control plane.

yaml
tenant_id: ten_abc123
proxy_id:  finance-proxy

# This proxy only receives policies tagged scope="finance" or scope="default"
scopes:
  - default
  - finance

control_plane:
  url:     https://api.clausgap.dev
  api_key: $CLAUSGAP_API_KEY

listeners:
  - protocol: http
    addr: :8080
    http:
      routes_file: /etc/clausgap/routes.yaml

How it works end-to-end

1

Author tags the policy

Set scope = "finance" in the DSL. The control plane stores this alongside the policy and includes it in every compiled bundle.

2

Proxy declares subscriptions

The scopes: list in proxy.yaml is sent with every /v1/proxies/heartbeat and used as a query parameter when polling /v1/policies/bundle?scopes=default,finance.

3

Control plane filters

The bundle endpoint extracts only policies whose scope matches the requested list, re-signs the result with the tenant's Ed25519 key, and returns it.

4

Proxy enforces subset

The proxy loads the filtered bundle and evaluates every agent request against only the policies relevant to its role — finance policies never reach an HR proxy, and vice versa.

Evaluate Clausgap

Before connecting anything real, run the evaluations that come with the Clausgap source. Each starts its own local stack with synthetic data, runs scripted checks end to end, and writes a report you can keep as evidence. They're industry-neutral — the same checks run for finance, healthcare and DevOps examples.

EvaluationTypeDescription
evaluation/kit54 checksA sandbox with a test identity provider, a mail catcher, the dashboard, a webhook receiver and a sample SDK agent. Proves SSO sign-in with roles and domain verification, agent identity and spoofing, approval-gated policy changes and break-glass, webhook testing and retries, and the agent's approve-then-run-once flow.
evaluation/approvals46 checksTwo-person approval: a held action runs only after two different approvers, exactly once, only for the requesting agent and only as approved; rejection and expiry stop it.
evaluation/evidence26 checksEvidence export verified offline with no network, again after a restart, and tampering — with the package or the database — detected.
ℹ

Private beta. The evaluation scripts live in the Clausgap source, which isn't public yet. Access comes with onboarding — email hello@clausgap.dev.

bash
./evaluation/kit/run.sh          # fresh kit, every scenario
./evaluation/kit/run.sh --up     # keep it running to explore
./evaluation/approvals/run.sh
./evaluation/evidence/run.sh