Here is a conversation that happens in a lot of companies right now:
Dana: @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 do DELETE_ISSUES on issue:PAY-123 in jira-main?
That's what I built 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:
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()
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
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?