DEV Community

Cover image for Your AI agent has more permissions than your users
Roee Hershko
Roee Hershko

Posted on

Your AI agent has more permissions than your users

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.

"Just tell the model not to"

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.

The usual fixes, and where they fall short

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:

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

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.

Ask the system that already knows

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

  • Kubernetes has SubjectAccessReview
  • Jira has a permissions API that answers per user
  • AWS can simulate a principal's IAM policies
  • GitHub reports a user's effective role on a repository

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"
}'
Enter fullscreen mode Exit fullscreen mode
{"decision":"deny","reason":"denied: ..."}
Enter fullscreen mode Exit fullscreen mode

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.

Three answers, not two

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.

What it looks like in an agent

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()
    # 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
Enter fullscreen mode Exit fullscreen mode

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: ...
Enter fullscreen mode Exit fullscreen mode

What it covers today

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.)

Limits worth knowing

  • It's a check, not a transaction. 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.
  • It trusts who the caller says the user is. hallpass answers "may this user…"; making sure the user really is Dana is your agent's job.
  • It only knows what the system exposes. If a permission can't be read with a read-only credential, the answer is unknown, and the docs for each integration say exactly what it can't see.

Try it

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
Enter fullscreen mode Exit fullscreen mode

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?

Top comments (2)

Collapse
 
pushpendraagrawal profile image
Pushpendra Agrawal

the unknown-treated-as-deny part is the whole thing. most permission wrappers people bolt onto agents only have two states, and the moment a call times out they fail open because nobody tested that path. we hit this exact issue building connector permissions for viaSocket, an API rate limit on the permission check itself can quietly become a bigger security hole than the agent you were trying to restrict.

Collapse
 
roee_hershko_bc6f44186f8e profile image
Roee Hershko

Thanks, that matches what pushed me to make unknown a first-class answer rather than an edge case. A permission check that fails open on a timeout is worse than no check, because everyone downstream trusts it.

Two things I did about it in hallpass, in case they're useful for viaSocket:

  • Every integration's tests run its fake upstream through the failure cases on purpose: 500, 429, 401 and a timeout, and assert the answer is unknown, never allow. The rate-limit case you describe is one of them, and unknown answers are never cached, so a burst of 429s can't get frozen into "deny for 30 seconds" either.
  • The client side is the other half. The Python client never raises on transport problems; a connection error, a malformed response or even an "allow" that arrives with a non-200 status all become unknown, so the guarded tool body can't run through an exception path nobody handled.

The honest limit: this only holds while callers treat unknown as deny. hallpass can't force that from the server side, which is why the decorators do it for you and the docs repeat it more than once.