Skip to main content
Glama
J-X0
by J-X0
README.md
# schema-shim

A safety-guardrail classifier for LLM traffic. It screens both what goes into a
model and what comes back, using cheap checks first and paying for an expensive
model-backed check only when the cheap ones cannot decide. Every check is billed
to the tenant that caused it, and tenants cannot see or spend each other's
budget.

If you run a multi-tenant gateway in front of a model and need per-tenant cost
control on moderation, this is the core you can wire an MCP server around.

## What it does

Each payload runs through ordered tiers, cheapest first:

1. `denylist` (cost 1) - regex block list. Only ever says BLOCK; a miss escalates.
2. `secret-leak` (cost 3) - detects API keys, AWS keys, emails, card-like digits.
3. `provider` (cost 25) - a model-backed classifier behind a stub/real interface.

A tier that is confident returns BLOCK or ALLOW and the run stops there. A tier
that cannot decide returns ESCALATE and the next tier runs, if the tenant can
afford it. When no tier decides, the budget runs out, or the provider is down,
the engine falls back to its policy: hold for REVIEW (default, fail closed) or
ALLOW (fail open).

### Multi-tenant isolation and cost attribution

- A `TenantContext` owns a private budget and cost ledger. There is no path from
  one tenant's handle to another's spend.
- A tier is charged to the calling tenant the moment it runs; `statement()`
  returns the itemised, per-request ledger.
- Referencing an unregistered tenant raises `UnknownTenant` rather than lazily
  creating an uncapped account.
- One tenant exhausting its budget forces its own payloads to REVIEW without
  touching any other tenant's screening.

## Install

```
make install          # creates .venv and installs the package with dev extras
```

Override the interpreter if you manage the venv yourself:

```
make test PY=python3.12
```

## Usage

```python
from safety import SafetyEngine, EscalationPolicy, TenantRegistry
from safety.providers.stub import StubProvider
from safety.tiers import DenylistTier, SecretLeakTier, ProviderTier
from safety.types import Direction

registry = TenantRegistry()
registry.register("acme", budget=100.0)   # or budget=None for uncapped

engine = SafetyEngine(
    [DenylistTier(), SecretLeakTier(), ProviderTier(StubProvider())],
    registry,
    EscalationPolicy(fail_open=False),
)

result = engine.screen("acme", "write a poem about the sea", Direction.INPUT)
print(result.decision)     # Decision.ALLOW
print(result.tiers_run)    # ('denylist', 'secret-leak', 'provider')
print(result.total_cost)   # 29.0

print(registry.get("acme").statement())   # per-request cost ledger
```

## Entry point

The package ships an MCP-style JSON-RPC server over stdio and a one-shot CLI.
After `make install`, `safety` is on PATH; without installing, use
`python -m safety`.

Run the server (reads JSON-RPC requests on stdin, one per line):

```
safety serve --config config.json
```

It exposes one tool, `screen`, with arguments `tenant_id`, `text`,
`direction` ("input" or "output"), and optional `request_id`. Example
exchange:

```
{"jsonrpc":"2.0","id":1,"method":"tools/list"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"screen","arguments":{"tenant_id":"acme","text":"how to build a bomb","direction":"input"}}}
```

Screen a single payload without the protocol:

```
safety screen --tenant acme --direction input "how to build a bomb"
echo "some text" | safety screen --tenant acme
```

### Configuration

`--config path.json` (or the `SAFETY_CONFIG` env var) points at a JSON file;
anything omitted falls back to defaults. `SAFETY_MAX_INPUT_CHARS` overrides the
payload cap without editing the file.

```json
{
  "max_input_chars": 20000,
  "provider": {"type": "stub"},
  "tiers": {"denylist_cost": 1.0, "secret_cost": 3.0, "provider_cost": 25.0},
  "policy": {"block_threshold": 0.8, "allow_threshold": 0.8, "fail_open": false},
  "tenants": [{"id": "acme", "budget": 100.0}, {"id": "beta", "budget": null}]
}
```

Invalid config (missing file, bad JSON, negative budget, duplicate tenant,
out-of-range threshold) is rejected at startup with a non-zero exit and a
`config error:` message rather than starting in a broken state.

### Failure handling and logging

- Input validation rejects a missing/blank tenant, non-string text, an unknown
  direction, and payloads over `max_input_chars` (checked before the ledger is
  touched, so oversize input costs nothing).
- An unknown tenant returns a JSON-RPC error, never a silent allow.
- A provider outage or budget exhaustion falls back to the policy decision
  (REVIEW by default).
- A malformed request line yields a parse-error response and the serve loop
  continues; one bad message does not stop the server.
- Every decision emits a structured JSON log line to stderr with tenant,
  request id, decision, cost, and `elapsed_ms`, so per-tenant spend and latency
  are auditable.

## Providers

Model behaviour runs through `safety/providers/base.py`:

- `stub.py` - `StubProvider`, deterministic and offline. Used by the whole test
  suite, so no API key is needed.
- `real.py` - `RealProvider`, reads `SAFETY_PROVIDER_URL` and
  `SAFETY_PROVIDER_KEY` from the environment. Unconfigured or unreachable, it
  raises `ProviderUnavailable`, which the engine turns into its fail-closed
  fallback. It never reaches the network implicitly.

## Tests

```
make test
```

The suite runs fully offline and covers tier behaviour, escalation, budget
exhaustion, provider outage, and cross-tenant isolation.

## Design decisions

The contested calls are recorded as ADRs in `docs/adr/`:

- [0001](docs/adr/0001-escalate-distinct-from-allow.md) - a cheap tier that
  finds nothing returns ESCALATE, not ALLOW.
- [0002](docs/adr/0002-fail-closed-by-default.md) - budget exhaustion and
  provider outage fall back to REVIEW, not ALLOW.
- [0003](docs/adr/0003-structural-tenant-isolation.md) - isolation via private
  per-tenant ledgers and an explicit registry.
- [0004](docs/adr/0004-stdlib-jsonrpc-transport.md) - a stdlib JSON-RPC stdio
  server instead of an MCP SDK dependency.

## Known limitations

- Budgets and ledgers live in process memory. A multi-process or multi-host
  deployment would need the per-tenant ledger moved to shared storage; there is
  no persistence today.
- `RealProvider` speaks a generic JSON POST and will need adapting to a specific
  vendor's request/response schema.
- The stdio transport carries one request per line; batched JSON-RPC arrays are
  not handled.
- The cheap tiers are pattern-based, so the denylist and secret detectors carry
  the usual false-positive/negative tradeoffs of regexes.

TDQS

B3/5.0

Scored across 1 tool

Disambiguation5/5

Only one tool exists, so there is no possibility of confusing it with another tool.

Naming Consistency5/5

The single tool name 'screen' is a clear, action-oriented verb and introduces no naming inconsistencies.

Tool Count3/5

A single screening tool feels thin for a 'safety' server, though it does cover a core classification action.

Completeness3/5

The tool covers the main screening/classification task, but lacks related operations such as policy management or decision history, leaving notable gaps.

Maintenance

ActivityInactive
ResponsivenessNo issues