<?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: Roee Hershko</title>
    <description>The latest articles on DEV Community by Roee Hershko (@roee_hershko_bc6f44186f8e).</description>
    <link>https://dev.to/roee_hershko_bc6f44186f8e</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%2F2442109%2Fc1452b3c-5d4b-4b2f-ad65-c04eae6a6581.png</url>
      <title>DEV Community: Roee Hershko</title>
      <link>https://dev.to/roee_hershko_bc6f44186f8e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/roee_hershko_bc6f44186f8e"/>
    <language>en</language>
    <item>
      <title>Your MCP Server Should Answer Questions, Not Expose APIs</title>
      <dc:creator>Roee Hershko</dc:creator>
      <pubDate>Mon, 12 Oct 2026 10:47:49 +0000</pubDate>
      <link>https://dev.to/roee_hershko_bc6f44186f8e/your-mcp-server-should-answer-questions-not-expose-apis-14c</link>
      <guid>https://dev.to/roee_hershko_bc6f44186f8e/your-mcp-server-should-answer-questions-not-expose-apis-14c</guid>
      <description>&lt;p&gt;&lt;em&gt;We plugged in 13 vendor servers and 300 tools, and they made our agent slow and dumb. Then we wrote one of our own with 30 tools, and the expensive model got cheaper.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first time Juno handled a real pager, I watched it the way you watch a toddler carry a full glass of water across a room.&lt;/p&gt;

&lt;p&gt;Juno is the AI bot we built for engineers in Slack. Someone gets paged at 2am because a service is throwing errors, and instead of opening six dashboards they ask Juno. I wrote about its system prompt in &lt;a href="https://levelup.gitconnected.com/every-prompt-rule-we-added-made-our-llm-agent-worse-da8c51093bcb" rel="noopener noreferrer"&gt;an earlier post&lt;/a&gt;. This one is about its tools, which turned out to be the bigger lesson.&lt;/p&gt;

&lt;p&gt;That first pager took Juno about fifteen minutes. It got the right answer. It also queried the metrics system eleven times, listed the pods twice, and spent most of the quarter hour gluing JSON together.&lt;/p&gt;

&lt;p&gt;We ran it on one of the cheaper models, because the expensive one would have cost real money per incident. Today Juno runs on the most expensive model we can buy. The same kind of incident takes a couple of minutes, and the thread costs less than it did.&lt;/p&gt;

&lt;p&gt;Nothing about the model made that happen. We changed what the tools are.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with help from an AI assistant, from my own notes and test results.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The free lunch
&lt;/h3&gt;

&lt;p&gt;When you build an agent today, the fastest way to give it powers is MCP, the protocol that lets a model call other systems. Every system you already have ships a server you can plug in: PagerDuty, Grafana, Jira, GitHub, the metrics database, the log platform. Ten minutes of config and your agent can “do anything”.&lt;/p&gt;

&lt;p&gt;So that’s what we did. Our first Juno was a coding-agent SDK pointed at our internal MCP gateway. Thirteen integrations. About three hundred and ten tools, plus a shell for kubectl.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2j90j42b1r08xl5og9vh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2j90j42b1r08xl5og9vh.png" width="799" height="341"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The metrics server alone exposed export_csv, export_native, reset_cache and build_info. Nobody on our team has ever asked a bot to export metrics as CSV. But the tool was there, with its description, and the model read it before every single step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three hundred tools, zero answers
&lt;/h3&gt;

&lt;p&gt;Watch an incident trace from that era and the real problem is obvious. Someone asks: “is the checkout service healthy in production?”&lt;/p&gt;

&lt;p&gt;A human on call knows what that means: are the pods up, are they restarting, is the error rate elevated, is latency up, did something deploy recently, what’s in the logs. One question, six facts.&lt;/p&gt;

&lt;p&gt;Juno had no tool that knew what a service was. It had a tool that lists pods. A tool that describes one pod. A tool that runs one metrics query. A tool that fetches logs for one container. So it did what the tools allowed: list the pods, pick one, describe it, query the error rate, query it again with a different label, fetch logs, forget to check latency, and answer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;The tools answered the systems’ questions. Nobody had built one that answered the person’s.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was the whole fifteen minutes. Not thinking. Plumbing. And every step was a chance to stop early, pick the wrong pod, or misread a label, which happened often enough that nobody trusted the result.&lt;/p&gt;

&lt;h3&gt;
  
  
  Our own tools had the same disease
&lt;/h3&gt;

&lt;p&gt;Here is the part that convinced me it wasn’t the vendors’ fault.&lt;/p&gt;

&lt;p&gt;By September we had replaced most of the MCP servers with tools we wrote ourselves. Reads against our clusters, our metrics, our config repos. Fast, scoped, ours. And the first version of our incident flow still took &lt;strong&gt;29 tool calls and eight minutes&lt;/strong&gt; on a real alert.&lt;/p&gt;

&lt;p&gt;We timed where the eight minutes went. 461 seconds was the model writing. 21 seconds was tools running.&lt;/p&gt;

&lt;p&gt;The tools were fast. The model was spending its time deciding which of our reads to call next and stitching the results together, because our reads were still shaped like the APIs underneath them: find the alert rule, run a metric query, list the resources, read the file. Every investigation opened with the same five reads, assembled by hand over four or five turns.&lt;/p&gt;

&lt;p&gt;So we folded those five reads into one call. Same alert, same model, same prompt.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdyzd9mz22ki3gqt3mjtq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdyzd9mz22ki3gqt3mjtq.png" width="798" height="223"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That alert, it turned out, had never been raised by our cluster at all. It came from an external daily check. Juno had reached that exact conclusion four times before, after 26, 42, 55 and 84 calls. With the bundled read it arrived on call one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Asking what people actually ask
&lt;/h3&gt;

&lt;p&gt;The real turn came when we stopped looking at our systems and looked at our Slack history.&lt;/p&gt;

&lt;p&gt;We pulled ninety days of pages, support tickets and production-channel threads and tagged what people were actually asking for. One question dominated: &lt;strong&gt;“is my service working?”&lt;/strong&gt; It was 38% of all high-urgency pages and more than half of the production channel. After it came “why is my deployment stuck”, “add replicas to X”, and “what changed”.&lt;/p&gt;

&lt;p&gt;Not one of those is an integration. Each is a question with a known shape, whose answer comes from four or five systems at once.&lt;/p&gt;

&lt;p&gt;So we built a tool for the question. get_service_health takes a service name and an environment and returns, in one call, everything the on-call engineer would have checked: replicas against desired, each unhealthy pod with its restart reason, warning events, the autoscaler, whether the deployment is in sync, request rate, 5xx rate, p99 latency, the service’s own metrics, and the error lines from its log. It also takes a time, because by the time anyone reads a page the blip is usually over, and “healthy right now” is the wrong answer.&lt;/p&gt;

&lt;p&gt;In our test suite the health scenarios went from 0 out of 6 passing to 6 out of 6. Nothing else moved. One tool, one question, fixed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The expensive model got cheaper
&lt;/h3&gt;

&lt;p&gt;One thing we stopped arguing about: how smart an agent is comes from the model, full stop. No prompt makes a model reason better than it can. What you control is how many turns a question takes and what each turn has to do. That is a tool design problem, and it decides whether you can afford the model you actually want.&lt;/p&gt;

&lt;p&gt;With three hundred tools the loop was long, so each turn was expensive, so we had to run the cheap model to make Juno viable at all. The cheap model is also the one that stops halfway through a chain of reads. We were paying for a mediocre engineer by the hour.&lt;/p&gt;

&lt;p&gt;With question-shaped tools the loop is short. A health question is one call. A “what changed” question is one call. The expensive model does the thing it’s actually good at, reading the facts and deciding, and does it in two or three steps instead of twelve.&lt;/p&gt;

&lt;p&gt;So we switched to the top model. Side by side on our test suite it scored 198 to 184 against the previous one, and on change requests it answered first and acted second three times out of three, where the other model managed it once. A full thread now costs between roughly $0.70 and $1.60.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Cost per token went up. Cost per answer went down. The tools decide which one you pay for.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  What we gave up
&lt;/h3&gt;

&lt;p&gt;This is not free, and the post would be dishonest if it ended before saying so.&lt;/p&gt;

&lt;p&gt;A vendor MCP server costs nothing to add and somebody else maintains it. Our tools are code we own. get_service_health knows that our service-mesh sidecar is a native init container, that our pods carry a specific label, that our autoscaler reports a specific metric. When the platform changes, the tool changes, and that’s our job now.&lt;/p&gt;

&lt;p&gt;Those 29 tools are an MCP server we wrote ourselves, and the same server feeds both Juno and the engineers’ own coding agents. The protocol was never the problem. The &lt;em&gt;shape&lt;/em&gt; of the tools was: they were built for a system’s API, not for a person’s question. If a vendor shipped a server with get_service_health on it, I’d use it tomorrow.&lt;/p&gt;

&lt;p&gt;And the one thing that still bites: the model only knows what a “service” is because we wrote a resolver that reads 41 configuration repos to map every name people use to the thing actually running. That resolver has had more bugs than any tool. Nobody gets to skip it.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you’re building one of these
&lt;/h3&gt;

&lt;p&gt;Today Juno has 29 tools. The number isn’t the point. Each one answers something a real person has actually asked, and these are the rules that got us there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don’t start from your integrations. Start from what your users actually ask.&lt;/strong&gt; Count the questions. Build a tool per question, not per API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One tool, one object.&lt;/strong&gt; A service, a rollout, a ticket. A new fact becomes a field on the existing tool, never a new tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One call answers the question.&lt;/strong&gt; If the model has to chain three reads, it will stop after two. When eleven deployment tools became two, the model made fewer calls and passed more tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facts, never verdicts.&lt;/strong&gt; A tool returns data. Ranking and deciding stay with the model, which can read a sentence. A script can only match words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name a tool after what it returns, in the words your users use.&lt;/strong&gt; Someone asks for “the logs of the service”, so the tool is get_service_logs. The name says what it is; the description says when to use it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time where the seconds go.&lt;/strong&gt; Ours were 461 seconds of model and 21 of tools. Fast tools don’t fix a slow loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then buy the good model.&lt;/strong&gt; Prompts don’t make a model smarter. Short loops make the smart model affordable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We started with every tool we could plug in and a model we could afford. We ended with the few tools people actually need and the model we actually wanted. The toddler puts the glass down in two minutes now, and it doesn’t spill.&lt;/p&gt;




</description>
      <category>devops</category>
      <category>agents</category>
      <category>ai</category>
      <category>mcpserver</category>
    </item>
    <item>
      <title>Every Prompt Rule We Added Made Our LLM Agent Worse</title>
      <dc:creator>Roee Hershko</dc:creator>
      <pubDate>Mon, 05 Oct 2026 15:49:28 +0000</pubDate>
      <link>https://dev.to/roee_hershko_bc6f44186f8e/every-prompt-rule-we-added-made-our-llm-agent-worse-2ojp</link>
      <guid>https://dev.to/roee_hershko_bc6f44186f8e/every-prompt-rule-we-added-made-our-llm-agent-worse-2ojp</guid>
      <description>&lt;p&gt;&lt;em&gt;Five weeks of measuring a DevOps agent in Slack: what made it shorter, what made it worse, and why the fixes ended up in code&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flnu0z11a9xx6g4fcmj8b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flnu0z11a9xx6g4fcmj8b.png" width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We built Juno, an AI bot that helps engineers in Slack. You ask it things like “why is my deployment stuck?” or “can we add more servers to this service?” It looks at our systems, and when something needs to change, it opens a pull request (a proposed change) for a person to approve.&lt;/p&gt;

&lt;p&gt;Every AI bot like this has a system prompt: a page of instructions it reads before every conversation. When Juno gave a bad answer, we did what most teams do. We added a new instruction to that page. The bot got worse. This post is about what we did instead, with the numbers. &lt;em&gt;Written with help from an AI assistant, from my own notes and test results.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  One bad answer, one new rule
&lt;/h3&gt;

&lt;p&gt;Juno recommended something we didn’t like, so we added “never headline a recommendation.” It made a change when it should have asked first, so we added “don’t start with today.” It trusted a setting it should have checked, so we added “check the config yourself.” Each new rule fixed the answer it was written for. Then the next bad answer showed up, and we wrote another rule.&lt;/p&gt;

&lt;p&gt;After about ten days I was ready to give up. Juno now argued with people, said no to normal requests, and asked people to do work it could do itself. We wanted a helper. We had built a bossy manager.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why more rules made it worse
&lt;/h3&gt;

&lt;p&gt;Most rules we wrote were “don’t do X.” We meant each one for a single situation. But the model doesn’t apply a rule to one situation. It applies it everywhere. Ten small “don’ts” made Juno careful and defensive about everything.&lt;/p&gt;

&lt;p&gt;Then we looked at the logs (the record of what Juno actually did). About half of the bad answers had nothing to do with the instructions. An access key had expired. A tool returned nothing. One part of the system skipped the step that fetches the data. No instruction can fix problems like these.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three new habits
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep the instructions short.&lt;/strong&gt; Say what the job is, not every case. Ours is now under 2,000 characters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If something must always happen, write code for it.&lt;/strong&gt; Don’t ask the model nicely. Code doesn’t forget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When an answer looks wrong, read the logs first.&lt;/strong&gt; Fix the real problem. If the instructions really are the problem, remove a rule. Don’t add one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third habit was hard, even for my AI coding assistant. Every time I showed it one bad answer, it suggested more instructions. At one point I told it: &lt;em&gt;“There might be nothing wrong.”&lt;/em&gt; One bad answer doesn’t prove anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  How we tested
&lt;/h3&gt;

&lt;p&gt;To remove rules safely, we needed to know which ones mattered. So we built tests. Each test is a short conversation, like “my deployment is stuck.” It runs against a fake copy of our systems, so every run sees the same facts. Some checks are simple code: did Juno use the right tool? How many words did it write? Other checks are done by a second AI model, which reads the answer and decides things like “did it ask before making the change?” A full test run cost between a few dollars and a few tens of dollars, which is cheaper than arguing about screenshots.&lt;/p&gt;

&lt;p&gt;Two things kept us honest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run each test several times.&lt;/strong&gt; AI answers vary. One run proves nothing, so we ran each test six times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare on the same day.&lt;/strong&gt; Models and tools change over time, so we always tested the old version and the new one side by side.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The one sentence that mattered
&lt;/h3&gt;

&lt;p&gt;We removed instructions one group at a time and re-ran the tests after each. Most of them made no difference at all: we deleted them and nothing changed. But one sentence made a big difference:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Read the cluster and the repo yourself with your tools; never ask the person to check something you can read.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Without it, test results dropped from 100 passing out of 102 to 93. In one test, Juno stopped looking at the data and did something else every single time. When we put that one sentence back, results went back to 101 out of 102.&lt;/p&gt;

&lt;h3&gt;
  
  
  A sentence that only looked guilty
&lt;/h3&gt;

&lt;p&gt;Another sentence told Juno to say “I can’t help” for topics outside its job. We thought it was causing a wrong refusal, so we tested it before deleting it. Without it, Juno made up an answer about a system it knew nothing about in 4 out of 6 runs. With it, 0 out of 6. So the sentence stayed.&lt;/p&gt;

&lt;p&gt;The real cause of the refusal was a tool description that didn’t explain what the tool could do. We fixed the description, and the refusal went away. You can’t tell which instruction matters just by reading it. You have to remove it and test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Asking for short answers made them longer
&lt;/h3&gt;

&lt;p&gt;Juno also wrote too much. Someone asked for a small change. Juno opened the pull request and wrote 168 words, and more than half of them repeated what the pull request already said. Our instructions already said: &lt;em&gt;“Be concise: complete sentences, no filler.”&lt;/em&gt; A popular blog post on making Claude less chatty (&lt;a href="https://joecotellese.com/posts/steering-claude-code-bluf" rel="noopener noreferrer"&gt;Opus 5 Made Claude Code Chatty. Three Changes Reined It In.&lt;/a&gt;) suggested adding a “give the main point first” section. We tried that, and two variations of it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F226o2oj9c2rfudknyble.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F226o2oj9c2rfudknyble.png" width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That advice worked for the author in a coding tool. For Juno, every version made the repeating worse, and the answers grew from about 71 words to 80, 90 and 108. Our best guess: if you mention the thing you don’t want, the model writes it. It didn’t matter whether we said “do this” or “don’t do that.” Mentioning the pull request text at all made Juno repeat it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Other things that didn’t work
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;“Reasoning effort” setting.&lt;/strong&gt; It made answers about a quarter shorter, but never removed a whole paragraph. It controls how hard the model thinks, not how much it writes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic’s own&lt;/strong&gt; &lt;a href="https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices" rel="noopener noreferrer"&gt;&lt;strong&gt;prompting advice&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;.&lt;/strong&gt; It helped a little. But when we asked Juno to “briefly explain” something, it still wrote 1,700 to 2,200 characters when the limit was 1,000.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A length limit in the instructions.&lt;/strong&gt; We wrote “max ~1,500 characters.” Every model we tried ignored it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What worked: deleting text
&lt;/h3&gt;

&lt;p&gt;What helped was removing text. Our documents had about 70 sentences telling Juno how to answer, like “report X” and “start with Y.” The data our tools sent back was full of little explanation notes. The more text the model reads, the more it writes. And instructions about how to answer end up inside the answer.&lt;/p&gt;

&lt;p&gt;We deleted those instructions and turned the notes into plain data. Then we re-ran six real questions from the day before. The word counts barely changed. But the answers were clearly better:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gone:&lt;/strong&gt; “Here’s what I found,” explanations of how to read the data, and headings named after tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kept:&lt;/strong&gt; the real facts, like numbers, times, what was missing and the suggested fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So we stopped chasing a lower word count. A short answer that drops the important facts is a worse answer.&lt;/p&gt;

&lt;p&gt;One deletion did break something. After we removed “don’t repeat the pull request link,” Juno pasted the link again in every test run. We didn’t put the sentence back. We wrote code that removes links Juno already showed. If a rule was hiding a display problem, replace it with code.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix that worked: a size limit in code
&lt;/h3&gt;

&lt;p&gt;Juno still wrote too much. Once it wrote about 170 words when the right answer was “Sure, which service and environment?” So we stopped asking and changed how Juno replies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Juno sends its answer through a “send message” tool.&lt;/li&gt;
&lt;li&gt;That tool has a hard character limit.&lt;/li&gt;
&lt;li&gt;The turn ends when the message is sent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flakw4zxbhqu471doumn7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flakw4zxbhqu471doumn7.png" width="800" height="269"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Adding more words to the instructions changed nothing. The limit did all the work. So the only real question is how long you want answers to be. We chose 600 characters, about three lines in Slack.&lt;/p&gt;

&lt;h3&gt;
  
  
  A surprise side effect
&lt;/h3&gt;

&lt;p&gt;Once “send message” was the last step, Juno changed how it worked. It did everything first, then reported. For a change in production, that meant opening the pull request before the person said yes. In our “ask first, then act” test, the pass rate dropped from 4 out of 6 to 1 out of 6. (The old version already did this about 40% of the time, which was news to us too.)&lt;/p&gt;

&lt;p&gt;We also tried another option: let Juno write freely, and if the answer is too long, ask it to shorten it. That got the same length. But once, the shortened answer said Juno had checked something it never checked. Shortening an answer after it’s written can turn “I didn’t check” into “I checked.” We kept the size limit. Next, “ask before changing production” will become code, not an instruction.&lt;/p&gt;

&lt;h3&gt;
  
  
  The warning we ignored
&lt;/h3&gt;

&lt;p&gt;We added a feature where Juno messages people when their pull request fails a check. Our tests showed these messages were about 200 words long, with code in every one. I thought that was fine and shipped it.&lt;/p&gt;

&lt;p&gt;The first real message was correct, but it was a wall of text: about 230 words, two code blocks, and a paragraph about file formats. The useful part, “I can push the fix,” was the very last sentence. When someone didn’t ask for a message, its length is everything. The test showed us the problem, and we ignored it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I’d tell anyone building an AI bot
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If it must always happen, write code.&lt;/strong&gt; But don’t use code for decisions the model should make. We once wrote code to sort support questions by matching words, and it got the first few real questions wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the instructions short.&lt;/strong&gt; When an answer is wrong, remove a rule. Don’t add one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don’t mention what you don’t want.&lt;/strong&gt; Not even as “don’t do this.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the logs before changing the instructions.&lt;/strong&gt; Half our “instruction problems” were broken tools and expired keys.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Test on the same day, and run each test several times.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send the model data, not notes.&lt;/strong&gt; Every explanation you send it, your users will read again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change the structure, not the wording.&lt;/strong&gt; A size limit did more than every instruction we wrote. It also changed how Juno worked, so test that too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We started out writing rules. We ended up deleting most of them, turning a few into code, and keeping one sentence we only knew mattered because we tried removing it.&lt;/p&gt;




</description>
      <category>promptengineering</category>
      <category>llm</category>
      <category>agents</category>
      <category>artificialintelligen</category>
    </item>
    <item>
      <title>I gave my AI agent one harmless permission. It became a backdoor for everyone.</title>
      <dc:creator>Roee Hershko</dc:creator>
      <pubDate>Thu, 24 Sep 2026 20:42:17 +0000</pubDate>
      <link>https://dev.to/roee_hershko_bc6f44186f8e/i-gave-my-ai-agent-one-harmless-permission-it-became-a-backdoor-for-everyone-355d</link>
      <guid>https://dev.to/roee_hershko_bc6f44186f8e/i-gave-my-ai-agent-one-harmless-permission-it-became-a-backdoor-for-everyone-355d</guid>
      <description>&lt;p&gt;&lt;em&gt;How one write path in a helpful bot turned into a permission problem, and the small service I built to close it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4h2x1mmyvfffs4q4f43k.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4h2x1mmyvfffs4q4f43k.gif" alt="hallpass answering allow, deny and unknown" width="600" height="310"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The agent
&lt;/h2&gt;

&lt;p&gt;I built an ops agent at work. You ask it what's wrong with a service and it comes back in about fifteen seconds with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;error rate and latency&lt;/li&gt;
&lt;li&gt;pod restarts and Kubernetes events&lt;/li&gt;
&lt;li&gt;node status&lt;/li&gt;
&lt;li&gt;the last pipeline that touched the service, and why it failed if it did&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A person clicking through dashboards takes ten minutes to get the same picture. The team was using it daily within a week.&lt;/p&gt;

&lt;p&gt;Reading was safe. Nobody gets hurt by a bot that looks at metrics.&lt;/p&gt;




&lt;h2&gt;
  
  
  Then I gave it hands
&lt;/h2&gt;

&lt;p&gt;"You found the problem, now fix it" was the obvious next step. In our world, fixing things means changing a YAML file in the GitOps repository.&lt;/p&gt;

&lt;p&gt;So I added one tool: &lt;strong&gt;open a pull request with the change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not merge it. Just open it. Humans still review. Branch rules still apply. Nothing reaches production without the usual gates.&lt;/p&gt;

&lt;p&gt;It felt conservative. It wasn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem, in one exchange
&lt;/h2&gt;

&lt;p&gt;Picture Dana. She's on a product team, she has &lt;strong&gt;read&lt;/strong&gt; access to the GitOps repository, and nothing more. She asks the agent:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Dana:&lt;/strong&gt; bump the memory limit for checkout in the payments namespace&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent:&lt;/strong&gt; Done. Opened PR #412 in acme/gitops.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Dana cannot push to that repository. She never could.&lt;/p&gt;

&lt;p&gt;The agent could, because the agent's token could. And the agent will open a PR for &lt;strong&gt;anyone&lt;/strong&gt; who talks to it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the usual fixes don't work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Just tell the model."&lt;/strong&gt; A line in the system prompt: &lt;em&gt;only act within the requester's permissions.&lt;/em&gt; Useless, for a precise reason: the model has never seen your GitHub org settings, and even if it guessed right, the tool call still runs with the bot's token. A prompt is a suggestion. The API call is what happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Write a policy."&lt;/strong&gt; Copy who-can-push-where into a config. You'd be copying a permission system that already exists, is maintained by other people, and changes every week. The copy is wrong within a month, and the dangerous kind of wrong: the kind that says &lt;em&gt;yes&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Ask the one who already knows
&lt;/h2&gt;

&lt;p&gt;GitHub knows exactly who can push to that repository. It's not a secret; there's an API for it.&lt;/p&gt;

&lt;p&gt;So before the agent opens a PR, it asks one question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;May &lt;strong&gt;this person&lt;/strong&gt;, the one who typed the request, push to &lt;strong&gt;this repo&lt;/strong&gt;?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Yes:&lt;/strong&gt; the agent goes ahead, with its own token, as before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No:&lt;/strong&gt; it says so and stops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can't tell&lt;/strong&gt; (timeout, rate limit, token rejected): it also stops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the part people forget. A permission check that fails &lt;em&gt;open&lt;/em&gt; when the network hiccups is worse than no check, because everyone downstream trusts it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same exchange, after
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Dana:&lt;/strong&gt; bump the memory limit for checkout in the payments namespace&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent:&lt;/strong&gt; I checked with GitHub first: your account has read access to acme/gitops, and opening a change there needs push. I haven't opened anything. Ask someone with write access, or I can draft the change for them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That reply is the whole feature. The agent didn't guess, didn't ask the model to judge, didn't fail quietly. It asked GitHub, GitHub said no, and Dana got a reason she can act on.&lt;/p&gt;




&lt;h2&gt;
  
  
  One line on the tool
&lt;/h2&gt;

&lt;p&gt;I pulled the check out of the agent into a small service called &lt;strong&gt;hallpass&lt;/strong&gt;, because once one write path was covered I wanted them all covered, and the agent talks to more than GitHub. Kubernetes can answer the same question for a named user. Jira has a permissions API. AWS can simulate a user's IAM policy. They all know. You just have to ask.&lt;/p&gt;

&lt;p&gt;In the agent it looks like this:&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="nd"&gt;@tool&lt;/span&gt;
&lt;span class="nd"&gt;@guarded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;github-main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo.push&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo:{owner}/{repo}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;open_config_pr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="c1"&gt;# runs only if GitHub says this user may push there
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;current_user&lt;/code&gt; comes from the session the request arrived in (the Slack identity, the SSO login), &lt;strong&gt;never from the model&lt;/strong&gt;. If the user were a tool argument, the model could be talked into filling it with an admin's email, and I'd have built a very polite privilege escalation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl686lz435pbh7y1bitxq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl686lz435pbh7y1bitxq.png" alt="Dana asks the agent, the agent asks hallpass, hallpass asks GitHub: deny" width="799" height="293"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What it deliberately doesn't do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't decide whether the agent &lt;em&gt;should&lt;/em&gt; make a change.&lt;/strong&gt; Reversible? 3 a.m.? Did the person ask, or did the model infer it? That's a different question, and in my setup the answer is still "open a PR and let a human decide". The check only makes sure the person proposing the change is allowed to propose it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't merge anything.&lt;/strong&gt; The repo's rules and reviewers still do that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't guess.&lt;/strong&gt; When a system can't tell it, the answer is &lt;code&gt;unknown&lt;/code&gt;, and unknown means no.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Where it is now
&lt;/h2&gt;

&lt;p&gt;hallpass is open source and talks to twenty-one systems. Two of them (Kubernetes and Argo CD) are tested against the real thing; most of the rest against the vendors' published API descriptions; a few are frankly beta and say so. It's a single Go binary, one config file, no database, and it sits next to whatever agent framework you use: Strands, LangChain, LangGraph, the Claude Agent SDK, or as an MCP server.&lt;/p&gt;

&lt;p&gt;I'm not going to tell you every agent needs this. If yours acts with each user's own credentials, it doesn't. If it's a personal coding assistant running as you, it doesn't.&lt;/p&gt;

&lt;p&gt;It's for the shared one. The bot in the channel that everyone talks to. The one that quietly became the most privileged account in the company.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/roee-hersh/hallpass" rel="noopener noreferrer"&gt;https://github.com/roee-hersh/hallpass&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wrote about the general problem, with more code, in an earlier post: &lt;a href="https://dev.to/roee_hershko_bc6f44186f8e/your-ai-agent-has-more-permissions-than-your-users-50in"&gt;Your AI agent has more permissions than your users&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;How do you handle this today? Per-user OAuth, one bot per team, or is a reviewer still the one catching it? I'd genuinely like to know.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>devops</category>
      <category>llm</category>
    </item>
    <item>
      <title>Anyone could make my AI agent do what they couldn’t</title>
      <dc:creator>Roee Hershko</dc:creator>
      <pubDate>Thu, 24 Sep 2026 20:39:24 +0000</pubDate>
      <link>https://dev.to/roee_hershko_bc6f44186f8e/anyone-could-make-my-ai-agent-do-what-they-couldnt-5caj</link>
      <guid>https://dev.to/roee_hershko_bc6f44186f8e/anyone-could-make-my-ai-agent-do-what-they-couldnt-5caj</guid>
      <description>&lt;p&gt;&lt;em&gt;A classic confused deputy, in an AI agent: how one write path turned into a permission problem, and the small service I built to close it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwccnii9uc4uluinyqmvx.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwccnii9uc4uluinyqmvx.gif" width="600" height="310"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The agent
&lt;/h3&gt;

&lt;p&gt;I built an ops agent at work. You ask it what’s wrong with a service and it comes back in about fifteen seconds with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;error rate and latency&lt;/li&gt;
&lt;li&gt;pod restarts and Kubernetes events&lt;/li&gt;
&lt;li&gt;node status&lt;/li&gt;
&lt;li&gt;the last pipeline that touched the service, and why it failed if it did&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A person clicking through dashboards takes ten minutes to get the same picture. The team was using it daily within a week.&lt;/p&gt;

&lt;p&gt;Reading was safe. Nobody gets hurt by a bot that looks at metrics.&lt;/p&gt;

&lt;h3&gt;
  
  
  Then I gave it hands
&lt;/h3&gt;

&lt;p&gt;“You found the problem, now fix it” was the obvious next step. In our world, fixing things means changing a YAML file in the GitOps repository.&lt;/p&gt;

&lt;p&gt;So I added one tool: &lt;strong&gt;open a pull request with the change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not merge it. Just open it. Humans still review. Branch rules still apply. Nothing reaches production without the usual gates.&lt;/p&gt;

&lt;p&gt;It felt conservative. It wasn’t.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem, in one exchange
&lt;/h3&gt;

&lt;p&gt;Picture Dana. She’s on a product team, she has &lt;strong&gt;read&lt;/strong&gt; access to the GitOps repository, and nothing more. She asks the agent:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;Dana:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;bump the memory limit for checkout in the payments namespace&lt;/em&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;Agent:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;Done. Opened PR #412 in acme/gitops.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Dana cannot push to that repository. She never could.&lt;/p&gt;

&lt;p&gt;The agent could, because the agent’s token could. And the agent will open a PR for &lt;strong&gt;anyone&lt;/strong&gt; who talks to it.&lt;/p&gt;

&lt;p&gt;Nobody was anonymous here. Dana was logged in, and the agent knew exactly who she was. The problem was whose permissions got checked: the agent called GitHub with its own token, so GitHub checked the bot, not Dana. Security people have a name for this: &lt;strong&gt;the confused deputy&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the usual fixes don’t work
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;“Just tell the model.”&lt;/strong&gt; A line in the system prompt: &lt;em&gt;only act within the requester’s permissions.&lt;/em&gt; Useless, for a precise reason: the model has never seen your GitHub org settings, and even if it guessed right, the tool call still runs with the bot’s token. A prompt is a suggestion. The API call is what happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“Write a policy.”&lt;/strong&gt; Copy who-can-push-where into a config. You’d be copying a permission system that already exists, is maintained by other people, and changes every week. The copy is wrong within a month, and the dangerous kind of wrong: the kind that says &lt;em&gt;yes&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;“ &lt;strong&gt;Use each user’s own token&lt;/strong&gt;.” The cleanest fix when you have one: the agent acts as Dana, and GitHub enforces everything itself. But a shared bot in Slack usually doesn’t have one. Nobody handed it their GitHub, Kubernetes or AWS token, and asking everyone to connect every system is a project of its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ask the one who already knows
&lt;/h3&gt;

&lt;p&gt;GitHub knows exactly who can push to that repository. It’s not a secret; there’s an API for it.&lt;/p&gt;

&lt;p&gt;So before the agent opens a PR, it asks one question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;May&lt;/em&gt; &lt;strong&gt;&lt;em&gt;this person&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;, the one who typed the request, push to&lt;/em&gt; &lt;strong&gt;&lt;em&gt;this repo&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Yes:&lt;/strong&gt; the agent goes ahead, with its own token, as before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No:&lt;/strong&gt; it says so and stops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can’t tell&lt;/strong&gt; (timeout, rate limit, token rejected): it also stops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the part people forget. A permission check that fails &lt;em&gt;open&lt;/em&gt; when the network hiccups is worse than no check, because everyone downstream trusts it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same exchange, after
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;Dana:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;bump the memory limit for checkout in the payments namespace&lt;/em&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;Agent:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;I checked with GitHub first: your account has read access to acme/gitops, and opening a change there needs push. I haven’t opened anything. Ask someone with write access, or I can draft the change for them.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That reply is the whole feature. The agent didn’t guess, didn’t ask the model to judge, didn’t fail quietly. It asked GitHub, GitHub said no, and Dana got a reason she can act on.&lt;/p&gt;

&lt;h3&gt;
  
  
  One line on the tool
&lt;/h3&gt;

&lt;p&gt;I pulled the check out of the agent into a small service called &lt;strong&gt;hallpass&lt;/strong&gt; , because once one write path was covered I wanted them all covered, and the agent talks to more than GitHub. Kubernetes can answer the same question for a named user. Jira has a permissions API. AWS can simulate a user’s IAM policy. They all know. You just have to ask.&lt;/p&gt;

&lt;p&gt;In the agent it looks like this:&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="nd"&gt;@tool&lt;/span&gt;
&lt;span class="nd"&gt;@guarded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;github-main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo.push&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo:{owner}/{repo}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;open_config_pr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="c1"&gt;# runs only if GitHub says this user may push there
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user comes from the session the request arrived in (the Slack identity, the SSO login), &lt;strong&gt;never from the model&lt;/strong&gt;. If the user were a tool argument, the model could be talked into filling it with an admin’s email, and I’d have built a very polite privilege escalation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3kgcreo3vvc56o3cizm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3kgcreo3vvc56o3cizm.png" width="799" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What it deliberately doesn’t do
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It doesn’t decide whether the agent should make a change.&lt;/strong&gt; Reversible? 3 a.m.? Did the person ask, or did the model infer it? That’s a different question, and in my setup the answer is still “open a PR and let a human decide”. The check only makes sure the person proposing the change is allowed to propose it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn’t merge anything.&lt;/strong&gt; The repo’s rules and reviewers still do that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn’t guess.&lt;/strong&gt; When a system can’t tell it, the answer is “unknown”, and unknown means no.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Where it is now
&lt;/h3&gt;

&lt;p&gt;hallpass is open source and talks to twenty-one systems. Two of them (Kubernetes and Argo CD) are tested against the real thing, most of the rest against the vendors’ published API descriptions, and a few are frankly beta and say so. It’s a single Go binary, one config file, no database, and it sits next to whatever agent framework you use: Strands, LangChain, LangGraph, the Claude Agent SDK, or as an MCP server.&lt;/p&gt;

&lt;p&gt;I’m not going to tell you every agent needs this. If yours acts with each user’s own credentials, it doesn’t. If it’s a personal coding assistant running as you, it doesn’t.&lt;/p&gt;

&lt;p&gt;It’s for the shared one. The bot in the channel that everyone talks to. The one that quietly became the most privileged account in the company.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/roee-hersh/hallpass" rel="noopener noreferrer"&gt;github.com/roee-hersh/hallpass&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;How do you handle this today? Per-user OAuth, one bot per team, or is a reviewer still the one catching it? I’d genuinely like to know.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>aiagentsinaction</category>
      <category>cybersecurity</category>
      <category>opensource</category>
      <category>llm</category>
    </item>
    <item>
      <title>Your AI agent has more permissions than your users</title>
      <dc:creator>Roee Hershko</dc:creator>
      <pubDate>Thu, 24 Sep 2026 05:54:34 +0000</pubDate>
      <link>https://dev.to/roee_hershko_bc6f44186f8e/your-ai-agent-has-more-permissions-than-your-users-50in</link>
      <guid>https://dev.to/roee_hershko_bc6f44186f8e/your-ai-agent-has-more-permissions-than-your-users-50in</guid>
      <description>&lt;p&gt;I run an internal ops agent at work. One tool call gives it a service's health from every angle, error counts, metrics, Kubernetes events, pod restarts, node status, in about fifteen seconds, where a model left to hunt through dashboards takes ten minutes. It reads everything, with a read-only account, and I don't gate the reads at all.&lt;/p&gt;

&lt;p&gt;It changes things one way only: by opening a pull request against our GitOps repository. And that single write path raised a question the agent could not answer on its own.&lt;/p&gt;

&lt;p&gt;Here is a conversation that happens in a lot of companies right now:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Dana:&lt;/strong&gt; &lt;a class="mentioned-user" href="https://dev.to/assistant"&gt;@assistant&lt;/a&gt; please close PAY-123 and delete the old release branch&lt;br&gt;
&lt;strong&gt;Assistant:&lt;/strong&gt; Done ✅&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The problem: Dana is not allowed to delete branches in that repository. The assistant is.&lt;/p&gt;

&lt;p&gt;Most AI agents and chat bots act in other systems (Jira, GitHub, Slack, Salesforce, AWS) through &lt;strong&gt;one service account&lt;/strong&gt;. That account needs enough access to help everyone, so it ends up with more access than any single person who talks to it. Whatever the agent can do, anyone who can reach the agent can do too.&lt;/p&gt;

&lt;p&gt;This isn't a new problem. ChatOps bots have had it for years. But agents make it much worse: they take free-form requests, they chain tool calls on their own, and they can be talked into things.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Just tell the model not to"
&lt;/h2&gt;

&lt;p&gt;The first fix most teams try is the prompt: &lt;em&gt;"Only perform actions the user is authorized for."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That doesn't work, for a simple reason: &lt;strong&gt;the model doesn't know what Dana is allowed to do&lt;/strong&gt;, and the tool call runs with the bot's credential whatever the model believes. A prompt is a suggestion. Authorization has to happen outside the model, in code, before the tool runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The usual fixes, and where they fall short
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Per-user OAuth.&lt;/strong&gt; The agent acts with Dana's own token, so the system enforces Dana's permissions. When it's available and practical, this is the cleanest answer and you should use it. In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;many systems have no simple "the bot acts as Dana" flow (Kubernetes RBAC, AWS IAM, Argo CD, and a lot of internal tooling);&lt;/li&gt;
&lt;li&gt;every user has to go through a consent screen for every system before the agent is useful to them;&lt;/li&gt;
&lt;li&gt;the agent now stores refresh tokens for everyone, which is a much bigger prize for an attacker than one read-only credential.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Your own policy engine.&lt;/strong&gt; Copy each system's permission model into OPA, Cedar or a config file, and check against that. It works on day one. After that, every project role, repository team, Jira permission scheme and IAM policy change has to be mirrored, and the copy quietly drifts from reality. A permission check that is wrong in the permissive direction is worse than none, because people trust it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask the system that already knows
&lt;/h2&gt;

&lt;p&gt;Every one of these systems already knows exactly what Dana may do. Most of them can even say so for a named user:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes has &lt;code&gt;SubjectAccessReview&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Jira has a permissions API that answers per user&lt;/li&gt;
&lt;li&gt;AWS can simulate a principal's IAM policies&lt;/li&gt;
&lt;li&gt;GitHub reports a user's effective role on a repository&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So instead of copying the rules, the agent can &lt;strong&gt;ask before it acts&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;May &lt;a href="mailto:dana@example.com"&gt;dana@example.com&lt;/a&gt; do &lt;code&gt;DELETE_ISSUES&lt;/code&gt; on &lt;code&gt;issue:PAY-123&lt;/code&gt; in &lt;code&gt;jira-main&lt;/code&gt;?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the question my agent had to ask before opening that pull request: may &lt;em&gt;this person&lt;/em&gt; push to &lt;em&gt;this repository&lt;/em&gt;? GitHub already knew the answer. &lt;a href="https://github.com/roee-hersh/hallpass" rel="noopener noreferrer"&gt;hallpass&lt;/a&gt; is that question, pulled out of the agent so every write path can use it, in every system the agent touches. It's a small, self-hosted service with one endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl localhost:8080/check &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
  "user": "dana@example.com",
  "connection": "jira-main",
  "action": "DELETE_ISSUES",
  "resource": "issue:PAY-123"
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"denied: ..."&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;hallpass asks Jira, live, with its own &lt;strong&gt;read-only&lt;/strong&gt; credential. It never performs the action; it only answers the question. The agent keeps its own credential and does the work, but only after the check says &lt;code&gt;allow&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three answers, not two
&lt;/h2&gt;

&lt;p&gt;The part I care most about is that hallpass has three answers: &lt;code&gt;allow&lt;/code&gt;, &lt;code&gt;deny&lt;/code&gt; and &lt;strong&gt;&lt;code&gt;unknown&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;deny&lt;/code&gt; means the system positively said no. &lt;code&gt;unknown&lt;/code&gt; means hallpass could not evaluate the question: the upstream timed out or rate-limited, hallpass's own credential was rejected, the resource isn't visible to it, or the policy uses a construct hallpass doesn't understand (an IAM condition, say). In all of those cases it doesn't guess, and callers should treat &lt;code&gt;unknown&lt;/code&gt; as deny.&lt;/p&gt;

&lt;p&gt;It sounds like a small detail, but it's the difference between a check you can trust and one that silently says yes when something breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in an agent
&lt;/h2&gt;

&lt;p&gt;The check belongs in the tool wrapper, not the prompt. A minimal Python version:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;HALLPASS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HALLPASS_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8080&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HALLPASS_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HALLPASS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/check&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                      &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resource&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                      &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# Anything other than an explicit allow, including "unknown" and errors, is a no.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;delete_issue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requesting_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;issue_key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requesting_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jira-main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE_ISSUES&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;issue:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;issue_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sorry, you&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;re not allowed to delete &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;issue_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;).&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="c1"&gt;# call Jira with the bot's credential
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is where &lt;code&gt;requesting_user&lt;/code&gt; comes from: the authenticated identity of whoever sent the message (the Slack user, the SSO session), never something the model wrote.&lt;/p&gt;

&lt;p&gt;If you use Strands, LangChain, LangGraph or the Claude Agent SDK, the repo ships a &lt;code&gt;@guarded&lt;/code&gt; decorator that does the same in one line on top of the framework's &lt;code&gt;@tool&lt;/code&gt;, with the user bound from your session so the tool schema never exposes a &lt;code&gt;user&lt;/code&gt; field:&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="nd"&gt;@tool&lt;/span&gt;
&lt;span class="nd"&gt;@guarded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jira-main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE_ISSUES&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;issue:{key}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;delete_issue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it covers today
&lt;/h2&gt;

&lt;p&gt;hallpass currently speaks to 21 systems: Jira, Confluence, GitHub, GitLab, Bitbucket, Slack, Google Workspace, Google Cloud, Microsoft 365, Azure, AWS, Kubernetes, Argo CD, Salesforce, Datadog, PagerDuty, Zendesk, Linear, Databricks, Snowflake and Vault. Each one is documented with the read-only credential it needs and what it cannot see.&lt;/p&gt;

&lt;p&gt;It's a single Go binary with one YAML file and no database. Secrets are only ever &lt;code&gt;env:&lt;/code&gt; or &lt;code&gt;file:&lt;/code&gt; references. Every integration is tested against a fake of its API that validates each request against the vendor's published OpenAPI description, and every resource parser is fuzzed nightly. (The fuzzer earned its keep this week: it found a Unicode control character slipping through a check that only rejected ASCII ones.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits worth knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's a check, not a transaction.&lt;/strong&gt; Permissions can change between the check and the action. Answers are cached for 30 seconds by default; set it to 0 if that matters to you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It trusts who the caller says the user is.&lt;/strong&gt; hallpass answers "may &lt;em&gt;this&lt;/em&gt; user…"; making sure the user really is Dana is your agent's job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It only knows what the system exposes.&lt;/strong&gt; If a permission can't be read with a read-only credential, the answer is &lt;code&gt;unknown&lt;/code&gt;, and the docs for each integration say exactly what it can't see.&lt;/li&gt;
&lt;/ul&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sO&lt;/span&gt; https://raw.githubusercontent.com/roee-hersh/hallpass/main/examples/hallpass.yaml
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:8080 &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;HALLPASS_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;change-me &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/hallpass.yaml:/etc/hallpass/hallpass.yaml:ro"&lt;/span&gt; ghcr.io/roee-hersh/hallpass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The example config has a fake integration, so you can see allow and deny answers in a minute without connecting anything real.&lt;/p&gt;

&lt;p&gt;The code is on GitHub under Apache 2.0: &lt;strong&gt;&lt;a href="https://github.com/roee-hersh/hallpass" rel="noopener noreferrer"&gt;https://github.com/roee-hersh/hallpass&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'd love to hear how you handle this today. Per-user OAuth everywhere? Separate bots per team? Human approval for anything destructive? And which system should hallpass support next?&lt;/p&gt;

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