{"slug": "your-ai-agent-has-more-permissions-than-your-users", "title": "Your AI agent has more permissions than your users", "summary": "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.", "body_md": "Here is a conversation that happens in a lot of companies right now:\n\n**Dana:** [@assistant](https://dev.to/assistant) please close PAY-123 and delete the old release branch\n\n**Assistant:** Done ✅\n\nThe problem: Dana is not allowed to delete branches in that repository. The assistant is.\n\nMost 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.\n\nThis 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.\n\nThe first fix most teams try is the prompt: *\"Only perform actions the user is authorized for.\"*\n\nThat 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.\n\n**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:\n\n**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.\n\nEvery one of these systems already knows exactly what Dana may do. Most of them can even say so for a named user:\n\n`SubjectAccessReview`\nSo instead of copying the rules, the agent can **ask before it acts**:\n\nMay [dana@example.com](mailto:dana@example.com) do `DELETE_ISSUES` on `issue:PAY-123` in `jira-main`?\n\nThat's what I built [hallpass](https://github.com/roee-hersh/hallpass) to do. It's a small, self-hosted service with one endpoint:\n\n```\ncurl localhost:8080/check -H \"Authorization: Bearer $KEY\" -d '{\n  \"user\": \"dana@example.com\",\n  \"connection\": \"jira-main\",\n  \"action\": \"DELETE_ISSUES\",\n  \"resource\": \"issue:PAY-123\"\n}'\n{\"decision\":\"deny\",\"reason\":\"denied: ...\"}\n```\n\nhallpass 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`.\n\nThe part I care most about is that hallpass has three answers: `allow`, `deny` and **`unknown`**.\n\n`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.\n\nIt 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.\n\nThe check belongs in the tool wrapper, not the prompt. A minimal Python version:\n\n``` python\nimport os\nimport requests\n\nHALLPASS = os.environ.get(\"HALLPASS_URL\", \"http://localhost:8080\")\nKEY = os.environ[\"HALLPASS_API_KEY\"]\n\ndef allowed(user, connection, action, resource):\n    r = requests.post(f\"{HALLPASS}/check\",\n                      headers={\"Authorization\": f\"Bearer {KEY}\"},\n                      json={\"user\": user, \"connection\": connection,\n                            \"action\": action, \"resource\": resource},\n                      timeout=10)\n    body = r.json()\n    # Anything other than an explicit allow, including \"unknown\" and errors, is a no.\n    return body.get(\"decision\") == \"allow\", body.get(\"reason\", \"\")\n\ndef delete_issue(requesting_user, issue_key):\n    ok, reason = allowed(requesting_user, \"jira-main\", \"DELETE_ISSUES\", f\"issue:{issue_key}\")\n    if not ok:\n        return f\"Sorry, you're not allowed to delete {issue_key} ({reason}).\"\n    ...  # call Jira with the bot's credential\n```\n\nThe 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.\n\nIf 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:\n\n```\n@tool\n@guarded(hp, \"jira-main\", \"DELETE_ISSUES\", \"issue:{key}\", user=current_user)\ndef delete_issue(key: str) -> str: ...\n```\n\nhallpass 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.\n\nIt'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.)\n\n`unknown`, and the docs for each integration say exactly what it can't see.\n\n```\ncurl -sO https://raw.githubusercontent.com/roee-hersh/hallpass/main/examples/hallpass.yaml\ndocker run --rm -p 8080:8080 -e HALLPASS_API_KEY=change-me \\\n  -v \"$PWD/hallpass.yaml:/etc/hallpass/hallpass.yaml:ro\" ghcr.io/roee-hersh/hallpass\n```\n\nThe example config has a fake integration, so you can see allow and deny answers in a minute without connecting anything real.\n\nThe code is on GitHub under Apache 2.0: [https://github.com/roee-hersh/hallpass](https://github.com/roee-hersh/hallpass)\n\nI'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?", "url": "https://wpnews.pro/news/your-ai-agent-has-more-permissions-than-your-users", "canonical_source": "https://dev.to/roee_hershko_bc6f44186f8e/your-ai-agent-has-more-permissions-than-your-users-50in", "published_at": "2026-09-24 05:54:34+00:00", "updated_at": "2026-09-24 06:00:15.039102+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "developer-tools", "ai-tools"], "entities": ["hallpass", "Jira", "GitHub", "Slack", "Salesforce", "AWS", "OPA", "Cedar"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/your-ai-agent-has-more-permissions-than-your-users", "markdown": "https://wpnews.pro/news/your-ai-agent-has-more-permissions-than-your-users.md", "text": "https://wpnews.pro/news/your-ai-agent-has-more-permissions-than-your-users.txt", "jsonld": "https://wpnews.pro/news/your-ai-agent-has-more-permissions-than-your-users.jsonld"}}