<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sangyeon Park</title>
    <description>The latest articles on DEV Community by Sangyeon Park (@sangyeonpark).</description>
    <link>https://dev.to/sangyeonpark</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4088767%2Fe449d5cd-30bf-4df9-be7d-7766f1c9695f.png</url>
      <title>DEV Community: Sangyeon Park</title>
      <link>https://dev.to/sangyeonpark</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sangyeonpark"/>
    <language>en</language>
    <item>
      <title>Put a Policy Gateway Between Your Coding Agent and the LLM</title>
      <dc:creator>Sangyeon Park</dc:creator>
      <pubDate>Thu, 27 Aug 2026 18:03:37 +0000</pubDate>
      <link>https://dev.to/sangyeonpark/put-a-policy-gateway-between-your-coding-agent-and-the-llm-22mg</link>
      <guid>https://dev.to/sangyeonpark/put-a-policy-gateway-between-your-coding-agent-and-the-llm-22mg</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;eval()&lt;/code&gt; on user input — you find out later, in review, or in a scanner run, or never.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;allow&lt;/code&gt;, &lt;code&gt;redact&lt;/code&gt;, or &lt;code&gt;block&lt;/code&gt; before the text reaches you.&lt;/p&gt;

&lt;p&gt;I'll use &lt;a href="https://github.com/cencurity/cencurity-engine" rel="noopener noreferrer"&gt;Cencurity Engine&lt;/a&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need first
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Go installed (the engine is a Go binary you run from source)&lt;/li&gt;
&lt;li&gt;An API key for whatever provider your agent already uses&lt;/li&gt;
&lt;li&gt;An agent or IDE that lets you override the API base URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;code&gt;OPENAI_API_BASE&lt;/code&gt; will work too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Start the gateway
&lt;/h2&gt;

&lt;p&gt;Clone the repo, open a terminal in it, and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go run ./cmd/cast serve &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--listen&lt;/span&gt; :8080 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--upstream&lt;/span&gt; https://api.openai.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy&lt;/span&gt; ./cast.rules.example.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three flags, and each one is doing something you should understand before moving on:&lt;/p&gt;

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

&lt;p&gt;Note what is &lt;em&gt;not&lt;/em&gt; in that command: your API key. The gateway forwards whatever &lt;code&gt;Authorization&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Check it before you trust it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go run ./cmd/cast doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;doctor&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The gateway also exposes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;http://localhost:8080/healthz&lt;/code&gt; — liveness&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;http://localhost:8080/metrics&lt;/code&gt; — Prometheus-format plaintext&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Curl &lt;code&gt;/healthz&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Repoint your agent
&lt;/h2&gt;

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

&lt;ul&gt;
&lt;li&gt;OpenAI-compatible: &lt;code&gt;http://localhost:8080/v1/chat/completions&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Anthropic Messages: &lt;code&gt;http://localhost:8080/v1/messages&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Gemini streaming: &lt;code&gt;http://localhost:8080/v1beta/models/{model}:streamGenerateContent&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Prove all three actions actually fire
&lt;/h2&gt;

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

&lt;p&gt;&lt;strong&gt;allow&lt;/strong&gt; — ask for something ordinary, like a function that sums a list. The stream should flow normally and the structured stdout log should carry &lt;code&gt;"action":"allow"&lt;/code&gt;.&lt;/p&gt;

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

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

&lt;p&gt;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 &lt;code&gt;[DONE]&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Write a rule of your own
&lt;/h2&gt;

&lt;p&gt;The policy file is JSON. Each rule takes six fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cast.custom.internal-hostname"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"secrets"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"medium"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"redact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"(?i)&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;b[a-z0-9-]+&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.internal&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.example&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.com&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pattern&lt;/code&gt; is a &lt;strong&gt;Go regex&lt;/strong&gt;, and that constraint matters more than it looks. Go's &lt;code&gt;regexp&lt;/code&gt; 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 &lt;code&gt;(?=...)&lt;/code&gt; 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.&lt;/p&gt;

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

&lt;p&gt;Start with &lt;code&gt;"action": "redact"&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Compare against the real provider before you roll out
&lt;/h2&gt;

&lt;p&gt;The question that decides whether anyone keeps this turned on is: &lt;em&gt;does routing through the gateway change what I get back?&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go run ./cmd/cast shadowtest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--upstream&lt;/span&gt; https://api.x.ai &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model&lt;/span&gt; grok-4-0709 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--api-key-file&lt;/span&gt; ./upstream-api-key.txt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--concurrency&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--iterations&lt;/span&gt; 5 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt; 90s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Run &lt;code&gt;allow-long&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you end up with
&lt;/h2&gt;

&lt;p&gt;When a rule fires you get a structured finding rather than a line number:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;language&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;framework&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fastapi&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rule_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cast.fastapi.auth.jwt-verify-disabled&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;severity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;high&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;confidence&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;high&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;action&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;block&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;evidence&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;eval(user_input)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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, &lt;em&gt;not a full semantic SAST engine&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://github.com/cencurity/cencurity" rel="noopener noreferrer"&gt;Cencurity&lt;/a&gt;, 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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>security</category>
      <category>go</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your Security Scanner Has a Blind Spot: Streaming</title>
      <dc:creator>Sangyeon Park</dc:creator>
      <pubDate>Thu, 27 Aug 2026 17:37:00 +0000</pubDate>
      <link>https://dev.to/sangyeonpark/your-security-scanner-has-a-blind-spot-streaming-1636</link>
      <guid>https://dev.to/sangyeonpark/your-security-scanner-has-a-blind-spot-streaming-1636</guid>
      <description>&lt;p&gt;I spent an afternoon convinced my detector was broken.&lt;/p&gt;

&lt;p&gt;I was building &lt;a href="https://github.com/cencurity/cencurity" rel="noopener noreferrer"&gt;Cencurity&lt;/a&gt;, a local gateway that sits between an IDE and an LLM provider and inspects generated code before it lands in the editor. The rule was simple. If the model produces a SQL query built by concatenating user input, block it. I had a regex. I had a test string. The test passed.&lt;/p&gt;

&lt;p&gt;Then I pointed a real coding agent at it, asked for something that should have tripped the rule, and watched the unsafe code arrive in my editor untouched.&lt;/p&gt;

&lt;p&gt;The rule was fine. My mental model of the input was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing nobody tells you about inspecting LLM output
&lt;/h2&gt;

&lt;p&gt;Every security tool I had used before this — linters, SAST scanners, pre-commit hooks — operates on a file. A complete, finished, sitting-still-on-disk file. You read it, you parse it, you walk it, you make a decision.&lt;/p&gt;

&lt;p&gt;LLM output is not a file. It is a stream.&lt;/p&gt;

&lt;p&gt;When an agent asks a provider for code, the response comes back over Server-Sent Events as a sequence of small chunks. Each chunk carries a fragment of text. The fragments are not split on anything meaningful — not on lines, not on tokens you would recognise, certainly not on syntactic boundaries. They are split on whatever the tokeniser and the transport happened to do.&lt;/p&gt;

&lt;p&gt;So a query that looks like this when it is finished:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM users WHERE id = &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;might arrive like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;data:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"choices"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"delta"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"cursor.execute(&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM us"&lt;/span&gt;&lt;span class="p"&gt;}}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;data:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"choices"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"delta"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ers WHERE id = &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; + reque"&lt;/span&gt;&lt;span class="p"&gt;}}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;data:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"choices"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"delta"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"st.args['id'])"&lt;/span&gt;&lt;span class="p"&gt;}}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My regex ran against each chunk as it arrived. No single chunk contained the pattern. Every chunk passed. The dangerous line assembled itself in the editor, one safe-looking fragment at a time.&lt;/p&gt;

&lt;p&gt;This is the part I want other people building in this space to have for free, because it cost me a day: &lt;strong&gt;a detector that is correct on a complete string can be silently useless on a stream, and it will not fail loudly. It will just never fire.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you can't simply buffer everything
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to stop being clever. Collect the whole response, wait for the stream to end, then run the detector once against the finished text.&lt;/p&gt;

&lt;p&gt;That works, and it is what I would do if the tool ran in CI. It does not work in an editor.&lt;/p&gt;

&lt;p&gt;The entire value of streaming is that the developer sees output as it appears. If you buffer the full response before releasing anything, you have converted a responsive agent into a tool that stares blankly for eight seconds and then dumps a wall of text. Users notice this immediately and they turn your tool off. A security control that gets disabled protects nothing.&lt;/p&gt;

&lt;p&gt;So the constraint is: inspect a stream, in real time, while still emitting it in real time, and catch patterns that no individual chunk contains.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually works: a sliding window with delayed release
&lt;/h2&gt;

&lt;p&gt;The approach that held up is to stop treating chunks as the unit of analysis. Chunks are a transport artefact. They should not be visible to the detector at all.&lt;/p&gt;

&lt;p&gt;The gateway keeps a rolling buffer of recently seen text. Each arriving chunk is appended, the detector runs against the tail of the accumulated buffer rather than the chunk alone, and then — this is the part that matters — the gateway releases only the portion of the buffer that can no longer participate in a match.&lt;/p&gt;

&lt;p&gt;Concretely: if your longest rule can match across N characters, you always hold back the last N characters. Everything before that is settled. Nothing arriving later can change whether it matched, so it is safe to forward.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chunk arrives
  |
append to buffer
  |
run detectors against buffer tail
  |
match?  --yes--&amp;gt;  allow / redact / block
  |
  no
  |
release buffer[0 : len-N]  --&amp;gt;  forward downstream
hold buffer[len-N : ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The developer sees output flowing continuously. The delay is bounded by N, not by the length of the response. And the detector sees continuous text, which is what it was written for.&lt;/p&gt;

&lt;p&gt;Three details that were not obvious to me at the start:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The window has to be sized off your rules, not guessed.&lt;/strong&gt; N is a property of your ruleset. Add a rule that can span more characters and your hold-back has to grow with it, or that rule quietly becomes a chunk-boundary lottery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You have to decide what happens to text you already released.&lt;/strong&gt; If a match completes at character 900 and you forwarded characters 0 to 850 twenty milliseconds ago, those 850 characters are already in the editor. Blocking now is a partial block. In Cencurity the enforcement actions are &lt;code&gt;allow&lt;/code&gt;, &lt;code&gt;redact&lt;/code&gt;, and &lt;code&gt;block&lt;/code&gt;, and how each behaves mid-stream is a design decision you have to make explicitly rather than discover in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every chunk is text.&lt;/strong&gt; SSE frames carry structure, and a naive "append the whole chunk" will happily let JSON scaffolding contaminate the buffer and either create false matches or break real ones. Parsing the frame before appending is not optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limits of this
&lt;/h2&gt;

&lt;p&gt;I want to be precise about what this is, because the AI security space is currently full of claims that do not survive contact with a real threat model.&lt;/p&gt;

&lt;p&gt;This is a heuristic, pattern-based, stream-time guardrail. It is not a semantic analyser. It has no type information, no data-flow graph, no notion of whether the variable being concatenated is actually attacker-controlled. Cencurity's own README says this in as many words: these are stream-time guardrails, &lt;strong&gt;not a full semantic SAST engine&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That means it will produce false positives on code that is fine, and it will miss things a real analyser would catch. What it does that a real analyser cannot is act &lt;em&gt;during authoring&lt;/em&gt; — before the code is in your file, before it is in your commit, before a scanner three steps downstream flags it and you have to reconstruct why you accepted it. When someone asked me how this differs from a code review tool, the distinction I landed on was: this is not static analysis of a repository after the fact, it is real-time control of the traffic between the agent and the model.&lt;/p&gt;

&lt;p&gt;Both layers are worth having. Neither replaces the other. Anyone telling you a regex over a token stream is a substitute for a SAST pipeline is selling something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I think this generalises
&lt;/h2&gt;

&lt;p&gt;Cencurity is a specific tool with specific choices — it runs as a local gateway on &lt;code&gt;127.0.0.1:38180&lt;/code&gt;, your API key never leaves your IDE, and only policy violations get logged rather than the whole conversation. Those are my choices and you might make different ones.&lt;/p&gt;

&lt;p&gt;But the streaming problem is not specific to me. It applies to anything that inspects model output in flight: prompt-injection filters reading tool-call arguments, PII redaction on chat responses, content policy enforcement on generated text. All of them are pattern-matching over a stream. All of them will silently under-fire if they treat the transport's chunk boundaries as real boundaries.&lt;/p&gt;

&lt;p&gt;The failure mode is the dangerous part. It does not throw. It does not log an error. Your dashboard shows zero violations and you conclude the traffic is clean.&lt;/p&gt;

&lt;p&gt;If you are building anything in this shape, the test to write first is not "does my rule match the bad string." It is "does my rule still match when I split the bad string at every possible offset and feed it through in pieces."&lt;/p&gt;

&lt;p&gt;Mine didn't.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sangyeon Park builds &lt;a href="https://github.com/cencurity/cencurity" rel="noopener noreferrer"&gt;Cencurity&lt;/a&gt;, 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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>llm</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
