<?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: Rahadian Bayu</title>
    <description>The latest articles on DEV Community by Rahadian Bayu (@teknokeras).</description>
    <link>https://dev.to/teknokeras</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%2F3994717%2Fce9e1553-c3a9-4f10-99f2-8e1fbeaa96ae.jpg</url>
      <title>DEV Community: Rahadian Bayu</title>
      <link>https://dev.to/teknokeras</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/teknokeras"/>
    <language>en</language>
    <item>
      <title>perso — a WebAssembly policy engine that decides what your MCP agent is allowed to do</title>
      <dc:creator>Rahadian Bayu</dc:creator>
      <pubDate>Sun, 21 Jun 2026 02:19:24 +0000</pubDate>
      <link>https://dev.to/teknokeras/perso-a-webassembly-policy-engine-that-decides-what-your-mcp-agent-is-allowed-to-do-2i8a</link>
      <guid>https://dev.to/teknokeras/perso-a-webassembly-policy-engine-that-decides-what-your-mcp-agent-is-allowed-to-do-2i8a</guid>
      <description>&lt;p&gt;If you're building anything on top of MCP (Model Context Protocol), you'll eventually hit this question: once an LLM decides to call a tool, who actually checks whether it's allowed to?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP's spec defines how tools are discovered and invoked — it says nothing about who's allowed to call what, or under which conditions&lt;/strong&gt;. That's left entirely to whoever builds the host. Left unaddressed, the default is wide open: any role can call any tool with any arguments. Bolt on a quick fix and you usually end up with one of two patterns: auth logic scattered across each tool implementation, or a coarse role check that can't actually look at the arguments (so "agents can process refunds" has no way to express "but only up to $500").&lt;/p&gt;

&lt;p&gt;Neither scales once you have more than a couple of roles and tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;perso is a small Rust project I built to give this problem a real answer: a policy enforcement engine for MCP tool calls, compiled to a single portable WebAssembly binary.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;You write your access rules as plain JSON — "agents can process refunds, but only up to $500," "managers can delete records they own," "this tool is blocked unless MFA is verified" — and perso compiles that into a .wasm binary. Drop that binary into any host (a backend server, an MCP server, an edge function, a CLI) and it answers one question, in microseconds, for every tool call: Allow or Deny.&lt;/p&gt;

&lt;p&gt;The LLM never sees or touches the role token — the host owns that, extracted from its own session/JWT. perso just evaluates the call against the policy and hands back a decision plus a human-readable reason.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;json{ "tool_name": "process_refund", "roles": ["agent"],&lt;br&gt;
  "condition": { "NumericCheck": { "source": "Arguments", "field": "amount", "op": "Lte", "value": 500.0 } } }&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That one rule is enough to stop an agent role from approving an $800 refund, no matter how convincingly the LLM was talked into trying.&lt;/p&gt;

&lt;p&gt;Conditions can check arguments, agent attributes, or resource attributes, and combine with All/Any/Not. The whole rule set gets pre-expanded at load time into a flat map, so every actual evaluation is an O(1) lookup plus a small condition check — no glob matching, no scanning, at request time.&lt;/p&gt;

&lt;p&gt;Default action is Deny: anything not explicitly allowed gets rejected.&lt;/p&gt;
&lt;h2&gt;
  
  
  Seeing it work: perso-demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/slIYrjy1es0"&gt;
  &lt;/iframe&gt;
 &lt;/p&gt;

&lt;p&gt;Reading rules in a README only goes so far, so I built perso-demo — a small chat app where an LLM (Groq, llama-3.1-8b-instant) calls tools against a mock B2B CRM, and perso intercepts every single tool call intent before it executes.&lt;/p&gt;

&lt;p&gt;You pick a role — agent, manager, or admin — and chat naturally:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Process a $200 refund for order ORD-8821" → allowed, under the agent's $500 cap&lt;br&gt;
"Try to process a $800 refund" → denied, NumericCheck fails&lt;br&gt;
"Delete customer C-9001's record" as a manager who doesn't own it → denied, FieldEquals fails (user_id != owner_id)&lt;br&gt;
"Run a bulk update" as admin without MFA → denied, the All condition needs both env: production and mfa_verified&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every decision shows up inline in the chat — green for allow, red for deny — with the exact reason from the policy engine. There's also a policy sidebar showing the raw rules and a live JSON panel, so you can watch a non-trivial RBAC + attribute-based policy enforced in real time without a single line of auth code inside the tool implementations themselves.&lt;/p&gt;
&lt;h2&gt;
  
  
  The SDK that wires it together
&lt;/h2&gt;

&lt;p&gt;The demo's backend doesn't talk to the raw WASM ABI directly — it goes through &lt;a class="mentioned-user" href="https://dev.to/teknokeras"&gt;@teknokeras&lt;/a&gt;/perso-sdk, the official Node.js SDK for perso.&lt;/p&gt;

&lt;p&gt;The raw WASM exports are just four C-style functions (alloc, dealloc, init, evaluate) that move length-prefixed JSON across the WASM memory boundary. The SDK wraps that into a clean async API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Perso&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@teknokeras/perso-sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;perso&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Perso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;path/to/perso.wasm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;path/to/policy.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;perso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;process_refund&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ORD-8821&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;agentAttributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agt-099&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// { decision: 'Deny', reason: '...' }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also adds structured audit logging on top, with pluggable transports (consoleTransport, httpTransport, fileTransport, or your own), so every decision can optionally be shipped somewhere durable for later review — useful when you need to show why an agent did or didn't do something, not just trust its own account of it.&lt;/p&gt;

&lt;p&gt;This is exactly the SDK perso-demo's backend uses: one shared Perso instance, loaded once at startup, sitting in front of every tool call before it reaches the mock CRM.&lt;/p&gt;

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

&lt;p&gt;A policy layer like this doesn't make an agent safe by itself — it doesn't stop an LLM from being manipulated into wanting to do something harmful. What it does is bound the blast radius once that happens: a hijacked agent still can't call bulk_update without env == production and mfa_verified, no matter what the prompt convinced it to attempt. Default-deny means anything the policy doesn't explicitly cover fails closed. It's one control among several you'd want in a production agentic system — alongside input validation, model-level guardrails, and monitoring — and it's still a control most MCP integrations don't have in place yet, even where general-purpose authorization tools (like OPA) could in principle be adapted to cover it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Engine: github.com/teknokeras/perso — Rust workspace, cargo build, compiles to wasm32-unknown-unknown&lt;br&gt;
Demo: github.com/teknokeras/perso-demo — pnpm install &amp;amp;&amp;amp; pnpm dev, needs a free Groq API key&lt;br&gt;
Node SDK: github.com/teknokeras/perso-sdk-node — npm install &lt;a class="mentioned-user" href="https://dev.to/teknokeras"&gt;@teknokeras&lt;/a&gt;/perso-sdk&lt;/p&gt;

&lt;p&gt;Happy to answer questions on the policy model, the WASM ABI, or how to embed perso in a non-Node host (it works the same way in Rust, Python, Go — anything with a WASM runtime).&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webassembly</category>
      <category>rust</category>
      <category>mcp</category>
    </item>
  </channel>
</rss>
