Your AI agent has more permissions than your users A developer built hallpass, a self-hosted authorization service that checks whether a specific user is permitted to perform an action in systems like Jira before an AI agent's tool call executes. Rather than copying each system's permission model into a policy engine, hallpass queries the upstream system live with a read-only credential and returns allow, deny, or unknown, with unknown treated as deny. The developer argues that per-user OAuth is the cleanest fix where practical, and that authorization checks belong in the tool wrapper rather than in the prompt. Here is a conversation that happens in a lot of companies right now: Dana: @assistant https://dev.to/assistant please close PAY-123 and delete the old release branch Assistant: Done ✅ The problem: Dana is not allowed to delete branches in that repository. The assistant is. Most AI agents and chat bots act in other systems Jira, GitHub, Slack, Salesforce, AWS through one service account . 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. 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. The first fix most teams try is the prompt: "Only perform actions the user is authorized for." That doesn't work, for a simple reason: the model doesn't know what Dana is allowed to do , 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. Per-user OAuth. 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: Your own policy engine. 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. Every one of these systems already knows exactly what Dana may do. Most of them can even say so for a named user: SubjectAccessReview So instead of copying the rules, the agent can ask before it acts : May dana@example.com mailto:dana@example.com do DELETE ISSUES on issue:PAY-123 in jira-main ? That's what I built hallpass https://github.com/roee-hersh/hallpass to do. It's a small, self-hosted service with one endpoint: curl localhost:8080/check -H "Authorization: Bearer $KEY" -d '{ "user": "dana@example.com", "connection": "jira-main", "action": "DELETE ISSUES", "resource": "issue:PAY-123" }' {"decision":"deny","reason":"denied: ..."} hallpass asks Jira, live, with its own read-only 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 allow . The part I care most about is that hallpass has three answers: allow , deny and unknown . deny means the system positively said no. unknown 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 unknown as deny. 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. The check belongs in the tool wrapper, not the prompt. A minimal Python version: python import os import requests HALLPASS = os.environ.get "HALLPASS URL", "http://localhost:8080" KEY = os.environ "HALLPASS API KEY" def allowed user, connection, action, resource : r = requests.post f"{HALLPASS}/check", headers={"Authorization": f"Bearer {KEY}"}, json={"user": user, "connection": connection, "action": action, "resource": resource}, timeout=10 body = r.json Anything other than an explicit allow, including "unknown" and errors, is a no. return body.get "decision" == "allow", body.get "reason", "" def delete issue requesting user, issue key : ok, reason = allowed requesting user, "jira-main", "DELETE ISSUES", f"issue:{issue key}" if not ok: return f"Sorry, you're not allowed to delete {issue key} {reason} ." ... call Jira with the bot's credential The important part is where requesting user comes from: the authenticated identity of whoever sent the message the Slack user, the SSO session , never something the model wrote. If you use Strands, LangChain, LangGraph or the Claude Agent SDK, the repo ships a @guarded decorator that does the same in one line on top of the framework's @tool , with the user bound from your session so the tool schema never exposes a user field: @tool @guarded hp, "jira-main", "DELETE ISSUES", "issue:{key}", user=current user def delete issue key: str - str: ... 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. It's a single Go binary with one YAML file and no database. Secrets are only ever env: or file: 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. unknown , and the docs for each integration say exactly what it can't see. curl -sO https://raw.githubusercontent.com/roee-hersh/hallpass/main/examples/hallpass.yaml docker run --rm -p 8080:8080 -e HALLPASS API KEY=change-me \ -v "$PWD/hallpass.yaml:/etc/hallpass/hallpass.yaml:ro" ghcr.io/roee-hersh/hallpass The example config has a fake integration, so you can see allow and deny answers in a minute without connecting anything real. The code is on GitHub under Apache 2.0: https://github.com/roee-hersh/hallpass https://github.com/roee-hersh/hallpass 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?