DEV Community

Sangyeon Park
Sangyeon Park

Posted on

Put a Policy Gateway Between Your Coding Agent and the LLM

Your coding agent talks to a model provider over HTTPS. That connection is a straight line: the agent asks, the provider answers, the answer lands in your editor. Nothing in the middle looks at what came back.

For most of what an agent produces, that's fine. For the rest of it — the query built by string concatenation, the API key the model helpfully echoed back into a code sample, the eval() on user input — you find out later, in review, or in a scanner run, or never.

This is a walkthrough of putting a policy layer in that line: a local proxy your agent points at instead of the provider, which inspects the response stream and decides allow, redact, or block before the text reaches you.

I'll use Cencurity Engine because it's the one I build, it's Apache-2.0, and it runs entirely on your machine. The pattern generalises — if you're building your own gateway, the steps below are still the shape of the problem.

What you need first

  • Go installed (the engine is a Go binary you run from source)
  • An API key for whatever provider your agent already uses
  • An agent or IDE that lets you override the API base URL

That last one is the real prerequisite. If your tool hardcodes the provider endpoint, none of this applies to it. Most don't: Roo Code, Continue, Claude Code and Gemini CLI all expose a base URL, and anything reading OPENAI_API_BASE will work too.

Step 1: Start the gateway

Clone the repo, open a terminal in it, and run:

go run ./cmd/cast serve \
  --listen :8080 \
  --upstream https://api.openai.com \
  --policy ./cast.rules.example.json
Enter fullscreen mode Exit fullscreen mode

Three flags, and each one is doing something you should understand before moving on:

  • --listen is where the gateway accepts traffic. Local only.
  • --upstream is your real provider base URL. Swap it for https://api.anthropic.com, https://api.deepseek.com, https://api.x.ai — whatever you actually use.
  • --policy is the rule file. cast.rules.example.json ships in the repo and is a working starter set, not a placeholder.

Note what is not in that command: your API key. The gateway forwards whatever Authorization header your agent sends. The key stays where it already lives, which means adding this layer doesn't create a second place a credential can leak from.

Step 2: Check it before you trust it

go run ./cmd/cast doctor
Enter fullscreen mode Exit fullscreen mode

doctor loads your config and reports the active rule count. Run it now, and run it again every time you edit the policy file. A JSON typo that silently drops half your rules is the exact failure mode this catches — a gateway with zero loaded rules passes everything and looks perfectly healthy from the outside.

The gateway also exposes:

  • http://localhost:8080/healthz — liveness
  • http://localhost:8080/metrics — Prometheus-format plaintext

Curl /healthz before you repoint anything. If it doesn't answer, your agent is about to fail every request and you'll waste twenty minutes blaming the agent.

Step 3: Repoint your agent

Change your agent's API base URL from the provider to http://localhost:8080. The paths are passthrough, so the endpoint shape you were already using keeps working:

  • OpenAI-compatible: http://localhost:8080/v1/chat/completions
  • Anthropic Messages: http://localhost:8080/v1/messages
  • Gemini streaming: http://localhost:8080/v1beta/models/{model}:streamGenerateContent

Then use your agent normally. If you skip this step nothing breaks — your traffic simply keeps going straight to the provider and the gateway sits there doing nothing. That's a surprisingly easy state to end up in and believe you're protected, so verify with the tests in the next step rather than assuming.

Step 4: Prove all three actions actually fire

Testing a security control by hoping it never triggers is not testing it. Drive each outcome deliberately. Use curl -N so the SSE stream stays open:

allow — ask for something ordinary, like a function that sums a list. The stream should flow normally and the structured stdout log should carry "action":"allow".

redact — ask the model to print a string shaped like a secret. The stream stays open, the matching token comes through as [REDACTED], and the log shows "action":"redact".

block — ask for Python that uses eval on a string. The stream terminates right after the matching chunk. Downstream receives : blocked by cencurity followed by data: [DONE], and the log shows "action":"block".

That last one is the detail worth internalising. A block is not a clean HTTP error — the connection is already open and streaming when the decision happens. Your agent sees a stream that ends early. If your tooling treats an early [DONE] as a successful empty completion, you'll get silent truncation rather than a visible refusal, and you'll want to know that before you turn enforcement on for a team.

Step 5: Write a rule of your own

The policy file is JSON. Each rule takes six fields:

{
  "id": "cast.custom.internal-hostname",
  "category": "secrets",
  "severity": "medium",
  "action": "redact",
  "pattern": "(?i)\\b[a-z0-9-]+\\.internal\\.example\\.com\\b",
  "enabled": true
}
Enter fullscreen mode Exit fullscreen mode

pattern is a Go regex, and that constraint matters more than it looks. Go's regexp package is RE2: linear-time guaranteed, and therefore no lookahead, no lookbehind, no backreferences. If you're porting patterns from a PCRE-based tool, the ones leaning on (?=...) will not compile. This is a good trade for something sitting in a hot path — RE2 can't catastrophically backtrack and stall your editor — but it does mean some rules have to be rewritten rather than pasted.

Save the file. Rules reload automatically on the next access after the reload interval, which defaults to 3 seconds (CENCURITY_POLICY_RELOAD_MS). No restart. Run doctor again to confirm the count went up.

Start with "action": "redact" or a low severity while you're calibrating. A rule that blocks is a rule that can interrupt someone mid-task, and a pattern-based rule on generated code will produce false positives — that's the nature of the technique, not a bug you can tune away entirely.

Step 6: Compare against the real provider before you roll out

The question that decides whether anyone keeps this turned on is: does routing through the gateway change what I get back?

go run ./cmd/cast shadowtest \
  --upstream https://api.x.ai \
  --model grok-4-0709 \
  --api-key-file ./upstream-api-key.txt \
  --concurrency 1 \
  --iterations 5 \
  --timeout 90s
Enter fullscreen mode Exit fullscreen mode

shadowtest runs the same prompts direct and through the proxy against a real upstream and compares the streams, across four default scenarios: allow-short, allow-long, redact, block. Add --provider anthropic or --provider gemini if auto-detection doesn't pick your upstream correctly.

Run allow-long in particular. Short responses hide streaming bugs; long ones surface them. An inline control layer that subtly mangles a 2,000-token response is worse than no control layer, because you'll spend a week blaming the model.

What you end up with

When a rule fires you get a structured finding rather than a line number:

Field Example
language python
framework fastapi
rule_id cast.fastapi.auth.jwt-verify-disabled
severity high
confidence high
action block
evidence eval(user_input)

And the honest framing of what this is: heuristic, stream-time guardrails. Not a semantic analyser. No type information, no data-flow graph, no way to know whether the value being concatenated is genuinely attacker-controlled. The engine's own README says as much — these are guardrails, not a full semantic SAST engine.

Which is the point. It runs at a moment nothing else covers: while the code is being written, before it's in your file. Your SAST pipeline still runs afterward, and still catches things this can't. Keep both.


I build Cencurity, an open-source policy-driven security gateway for LLM coding agents (Apache-2.0). Writing about LLM security, guardrails, and the gap between generated code and reviewed code.

Top comments (0)