About the project
Inspiration
The honest answer to a question is sometimes "you are not allowed to know that," and almost no agent demo is built to say it.
Enterprises do not have one knowledge base. They have departments, and each one has different rules about who may read it. Point an agent fleet at all of them and you have built an access control system, whether or not you designed it as one. Most projects discover this late, and patch it by filtering the model's output.
That patch is the thing we wanted to argue against. Filtering output after the fact is not access control. By the time you are filtering, the restricted content has already been assembled into a prompt and sent to a model provider. The gate has to sit in front of the read, not behind the answer.
Once we accepted that, a second idea followed. If the fleet refuses you something, the refusal is information. An answer that quietly omits what it could not reach is a worse answer than one that shows you the shape of the boundary it hit. So Cartographer draws the files it was denied, dashed and locked, right on the map next to the ones it used. That is where the name comes from: the interesting part of a map has always been the edges.
What it does
You ask one question. A state manager plans it into per department gathering tasks and fans out a fleet of gatherer agents in parallel. Every file each gatherer touches is checked against your role before it is opened. What survives is synthesised into an answer with inline citations, and then an adversarial veto checker tries to reject it: for unsupported claims, for citations that do not resolve, for departments the config never allowed. If it vetoes, the answer is revised and re checked.
The whole run streams into a graph while it happens. Files you were denied stay on the map, locked.
Then there are the parts that exist because a permission system nobody can inspect is just a claim:
- an access ledger, append only, recording every decision both gates made
- citation coverage, scoring how well the answer is anchored to what was actually gathered
- a side by side role comparison, running one prompt under two roles at once so you can see the shape of what a role costs you
- replay, which records a real run and plays the exact event stream back with no API calls at all
How we built it
Two of us, splitting the pipeline down a shared contracts layer. Every message
between components is a Pydantic model in src/agentic/contracts/, and changing
a field there is a contract change we agree on before it lands. It meant we
could work on opposite ends of the same run without blocking each other.
The permission rule is deliberately boring, and it is an AND, not an OR:
$$ \text{read}(r, d, p) \iff \underbrace{G_{\text{perm}}(r,d)}{\text{permissions.yaml}} \;\wedge\; \underbrace{G{\text{env}}(r,d)}{\text{environment.yaml}} \;\wedge\; \underbrace{\text{contained}(p,d)}{\text{no escaping the root}} \;\wedge\; \underbrace{\text{exists}(p,d)}_{\text{the department really holds it}} $$
Two independent config files must both grant the role that department. Any disagreement denies, and malformed config fails closed. That expression is enforced at five points in the pipeline rather than one, because the gatherer's own pre read check is an efficiency layer and the boundary has to be somewhere a model cannot reach.
The role itself comes from a verified JWT and nothing else. Nothing a model emits is ever trusted as an access decision. The planner's department names are validated against a closed schema built per request, so a hallucinated department fails validation before any code runs on it.
The stack:
- Gemini 3.5 Flash on Vertex AI for the planner, the gatherers and the synthesiser, through the Google Agent Development Kit
- Claude for the veto checker, on a structured verdict the operator can swap at run time from an allowlist
- Google Cloud Storage for cloud backed departments, where the department name is the bucket prefix and the prefix is the containment root, so a cloud object passes exactly the same gate as a local file
- Cloud Run hosting the server, reaching Vertex and the bucket through the runtime service account's Application Default Credentials, so the deployed image carries no key file and no credentials at all
- FastAPI with server sent events, so the browser watches the fleet work file by file instead of waiting on a spinner
- vis-network for the graph, driven by one shared graph builder so the live app and the self contained offline page always draw the same thing
Third party code and data
The graph is drawn with vis-network
9.1.9, vendored at src/agentic/client/vendor/vis-network.min.js so the offline
graph page needs no CDN; it is dual licensed Apache 2.0 / MIT and ships with its
license header intact. Everything else in src/ we wrote during the submission
period. The departments under data/ are synthetic content we authored for the
demo, including the planted injection payload; no real company data, and no
external dataset, is used anywhere in the project.
245 tests, about 4,000 lines of them against 4,800 lines of source. The pipeline is faked at its seams so the permission gates, the injection defences, the audit ledger and the GCS wire protocol are all covered without API keys.
Challenges we ran into
No model call had ever actually run. We had a green test suite and a working
pipeline and, it turned out, an .env file nobody loaded. Every live test was
decorated to skip without API keys, so they skipped, silently, and reported as
passing. The suite was green because it was not looking. That is the most
uncomfortable bug we have ever found in our own project.
The free tier is 20 requests per day per model. One run costs five to eight calls, so roughly three runs a day, which is not a demo. Moving to Vertex AI fixed it.
Gemini 3.5 returned a bare 404, which reads exactly like the model does not
exist. It does exist. It is not served from regional endpoints. Setting
GOOGLE_CLOUD_LOCATION=global fixed instantly what we had spent real time
believing was a model availability problem.
The force directed graph exploded. Coordinates in the $10^{22}$ range, every hidden node collapsed onto the origin. The integration loop was running over all nodes while the force loops skipped invisible ones, and an unbounded inverse square term did the rest. Integrating only the live set, clamping force and speed, and nudging coincident nodes brought it back.
Containment is not provenance, and we only found out because we attacked
ourselves. We planted a real prompt injection payload in the demo data and
wrote tests for the strongest form of the attack: assume the injection fully
worked and the gatherer returns the HR files it was told to. The test failed.
The gate kept them. A path like hr/comp-bands.md returned under
department="engineering" is contained, because it names a subdirectory of
engineering that happens not to exist, so nothing about it escapes the root.
Real HR data was never at risk, since the gatherer never had it, but a
fabricated citation could arrive wearing a real department's name. The gate now
asks two questions instead of one: may this role read this path, and is this
path a file the department actually holds.
The veto checker caught a real hallucination the first time it fired live.
The synthesiser cited [[finance/infra-spend-q3.md]] when the file was
[[engineering/infra-spend-q3.md]]. Right filename, wrong department, and in
rendered markdown the two are indistinguishable. It was rejected, revised, and
approved on revision 1 with every figure verified. That single incident is why
citation coverage exists as a feature.
Our Anthropic credits ran out in the middle of building. The veto checker is Claude, so nothing could complete. Rather than wait, we built replay mode, which records a real run's event stream and plays it back with zero API calls. A constraint turned into the thing that makes the demo survive a dead key at judging time.
Accomplishments we are proud of
The permission story is not a paragraph in a README, it is a thing you watch happen. Ask as an analyst and four HR files lock on the map before a single byte is read. Ask as an admin and they open. Same prompt, same fleet.
And the injection file is sitting in the demo data the entire time, on every single run, quietly failing.
What we learned
- A skipped test is not a passing test. Green means nothing until you have checked what it is green about.
- Containment answers a different question than provenance, and we were only asking one of them.
- Defending against prompt injection is architectural, not textual. We never wrote a single instruction telling a model to resist the payload. It fails because by the time any model sees it, every decision it is trying to influence was already made by config it cannot reach.
- Something has to be optimising against the answer. Every other component in a pipeline is trying to produce output. Without a component whose job is to reject, nothing is.
- Fail closed, and say so out loud. An answer that exhausts its retries still ships, carrying a visible unapproved banner, never a silent one.
What is next
Multi tenant deployment with a shared ledger, tamper evidence on the audit log so it is a compliance artifact rather than a record, and more storage backends behind the same gate.
Built With
- anthropic
- asyncio
- canvas
- claude
- css3
- fastapi
- gemini
- github
- google-adk
- google-cloud
- google-drive-api
- google-genai
- html5
- javascript
- jwt
- pbkdf2
- pydantic
- pytest
- python
- server-sent-events
- uvicorn
- vertex-ai
- yaml
Log in or sign up for Devpost to join the conversation.