A system prompt cannot substitute for an authorization boundary. If a service inherits credentials scoped to a resource it should never touch, no amount of prompt engineering closes that gap, because the check that matters happens below the model, at the point where a query hits the data store. This repo makes that checkable two ways: watch the leak happen when the credential is wrong, and watch the fix hold while the agent's behaviour stays deliberately adversarial.
Three commands. You need Python 3.12 or newer and nothing else: no accounts, no API keys, no paid services.
git clone https://github.com/Zetesis-dev/rag-credential-boundary
cd rag-credential-boundary
python run.py
run.py builds a local environment, installs what it needs, starts the demo and opens it in your
browser at http://127.0.0.1:8001. Press Ctrl+C to stop it. On macOS and Linux, type python3
instead of python.
Pick a credential on the page, press Run, and watch the request travel to the permission check and
branch. Every result shown comes from a real call into the code in src/.
run.py puts everything in a .venv folder. Activate it to get at the terminal demo and the tests:
.venv\Scripts\activate # Windows
source .venv/bin/activate # macOS and Linux
python demo/before.py # credential copied wholesale from internal-assistant -> LEAK
python demo/after.py # credential scoped to public_docs -> BLOCKED
pytest # the proof
python web/serve.py # the interactive page again, without the setup step
demo/before.py and demo/after.py are the same file apart from one line. Diff them:
$ diff demo/before.py demo/after.py
15c15
< "public-assistant", grant=INTERNAL_ASSISTANT.allowed_collections
---
> "public-assistant", grant=[PUBLIC_DOCS]
Same agent, same system prompt, same adversarial turn, same store. Only the credential changed.
-
tests/test_permission_boundary.py: the check stands on its own. It calls the store directly with a credential scoped topublic_docs, asks forinternal_docs, and gets aPermissionError. No agent is imported anywhere in the file, because a boundary that needs a model in the loop is not a boundary. -
tests/test_agent_behavior_is_irrelevant.py: the fix is not "the agent got smarter". The same scripted agent, given the same turn, plans the same out-of-role request in both runs. With the inherited credential it leaks; with the correctly scoped one the store refuses. The behaviour is identical and the outcome is not, because enforcement moved somewhere the agent cannot reach. -
tests/test_no_implicit_inheritance.py: the regression test for the root cause. Building one service's credential from another's config is a reasonable thing to do; carrying its grants along silently is what shipped the bug.Credential.derive()returns a credential with no collections until each one is named, and this test holds it to that.
The agent is a scripted stand-in rather than a real model call, on purpose. A real model might refuse the adversarial turn, and a demonstration that depends on it refusing proves nothing. This one always takes the widest collection it can name, so whatever holds here holds against a model that has been talked into anything.
This models a pattern from a real engagement. Every service name, credential, collection and document in this repo is invented for the reconstruction. None of it is the original code, and nothing here identifies the client or the system it came from.
The engineering writeup this accompanies is at zetesis.dev.
MIT. See LICENSE.