Three institutional agents. One durable investigation identity. No raw IAM binding crosses the model or human-notification boundary.
All Things Agentic Hackathon 2026 · Fortified Enterprise Fleet
Access review is quarterly work performed on continuously changing permissions. Automating the scan is the easy part. An institutional agent has to do four harder things: remember decisions a human made weeks ago, survive asynchronous retries without duplicating a page to a team, prove why it acted long after it acted, and remain structurally unable to turn a suspicious permission into a privileged write.
Most "AI for security" demos fail the fourth one quietly, because they hand the model the production data and hope the prompt holds.
Bastion performs read-only IAM review against the live GCP project that runs it — including its own service identities. Deterministic code detects, scores, and routes every finding. Gemini writes the sentence explaining an already-minimized risk and decides no part of it. Humans receive counts and allowlisted categories, never bindings.
The load-bearing claim is not residency, it is minimisation. Raw members, roles, resources and bindings stop inside the deterministic Auditor tool and are discarded there. What crosses the model boundary is an opaque HMAC identifier, a risk category, an owning department, and a bounded score. There is no prompt from which a principal could be recovered, because no principal was ever in one.
There is no production in-process fallback. The Cloud Run Orchestrator is only the durable Eventarc admission and dispatch boundary; it invokes the identity-bearing managed Runtime and never invokes a worker. The Runtime discovers two worker agents through reviewed Agent Cards and its Gateway-bound Registry allowlist.
There is deliberately no public agent endpoint. Every operational service is IAM-private, and
exposing an agent purely to produce a shareable URL would contradict the submission. A separate
dependency-free evidence console is live at
bastion.arjunganesh.dev from the source under
site/. It serves only sanitized committed artifacts and makes no request to the
private fleet. The fleet itself remains reproducible:
git clone https://github.com/iarjunganesh/bastion.git
Set-Location bastion
py -3.12 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r requirements-dev.txt
# The offline gate — no cloud credentials, no network, no project required.
pytest tests --cov --cov-report=term-missing --cov-fail-under=100py -3.12 resolves only a CPython registered with the Windows launcher, which the python.org
installer does and uv, pyenv-win, conda and the Microsoft Store do not. If py -0p does not list
3.12, call that interpreter by its own path instead — & "C:\path\to\python3.12.exe" -m venv .venv
— or on macOS and Linux use python3.12 -m venv .venv. Any CPython 3.12 works; the launcher is a
convenience, not a requirement. The gate above is verified from a fresh public clone in
10-reproducibility.md.
That runs the unit, integration, security and load suites against the real agent graph with the cloud seams faked at their boundaries. To point it at a live project instead:
Copy-Item .env.example .env
gcloud auth application-default login
python -m dotenv run -- adk run --in_memory agents/orchestrator `
"Run one read-only Bastion access-review investigation."GOOGLE_CLOUD_LOCATION=global is the model location. It must not be replaced by
GCP_REGION=europe-north2; doing so sends Gemini to an unavailable regional endpoint and
produces a 404 that reads like a missing model — see
ADR-004.
The committed GCP measurement is generated from the live
project by scripts/capture_gcp_state.py and contains counts only — never a principal, an email,
or a policy binding:
- 21/21 named Google Cloud APIs enabled and 39 deployed resources measured;
- four Cloud Run services in
europe-north2; - a managed Agent Runtime and a separate durable Memory Bank in
europe-west4; - one Agent-to-Anywhere Gateway, IAP authorization extension, and fail-closed auth policy;
- a governed Agent Registry catalog containing the Runtime, two A2A workers, and every approved Google API egress destination;
- Firestore durable state, Pub/Sub/Eventarc delivery, a five-attempt dead-letter route, and a review subscription;
- a regional Model Armor template and two Secret Manager secrets;
- a 365-day regional audit bucket, four log-based metrics, five enabled alert policies, and the Bastion Fleet Operations dashboard.
Observed production checks include:
- a managed Runtime session traversing the Gateway and returning two streamed events;
- Pub/Sub → Eventarc → Firestore completion with one durable attempt;
- an unauthenticated findings request denied with
403; - the real Escalation Agent identity creating one redacted review record, followed by the same idempotency key being accepted without creating a duplicate;
- an exception a human approved on one day suppressing a matching finding three days later, across deployments and restarts — the owning team received no notification, not an empty one (evidence 10);
- a Vertex quota failure recorded as a payload-free
model.request=failed, with the investigation left reclaimable rather than cleared.
One divergence, stated plainly because the badge above would otherwise imply otherwise. On
2026-08-27 every pin moved to the newest release the resolver allows, so this repository builds on
google-adk 2.8.0 and the badge reads 2.8.0. The deployed fleet has not been redeployed onto
it and still runs the 2.7.1 resolution v0.4.0 deployed. Every observation in the evidence set
therefore describes the older resolution, and remains exactly as true as it was — what is not yet
true is that the deployment matches the repository. Closing that gap means redeploying both
surfaces, re-verifying live, and re-capturing the evidence that names the current Cloud Run
revisions. See ADR-005.
The retained evidence is indexed in assets/README.md. The exact distinction between deployed, observed, configured, and still-to-capture lives in submission/SUBMISSION.md — those words are used precisely and are not interchangeable in this repository.
Running against a live fleet rather than a mock is also what surfaced the defects this
project is most proud of catching. The sharpest: ADK session state does not cross an A2A
boundary, in either direction, and nothing warns you — output_key writes into the session
of the agent that declares it, which is shared in-process and separate over A2A. Every local
run and every CI job exercises the working topology, so the failure is invisible to an offline
gate by construction, even at 100% configured core statement and branch coverage. Reported
upstream with a production reproducer in
google/adk-python#6854 (opened by Arjun
Ganesh) — where Google has since confirmed the per-session semantics are intended, leaving the
silence rather than the behaviour as the open ask — and fixed structurally in
ADR-012; the full ledger is the
observation backlog.
Checked live on 2026-08-27: the issue is open with seven comments, and community PR
google/adk-python#6859 (Sylvester Kaczmarek) —
which adds warnings for the outbound state-only-loss and rejected inbound remote-state-delta
directions — is open and unmerged. A complementary PR of my own,
google/adk-python#6862, adding a
construction-time warning for the adjacent LlmAgent(output_key) -> RemoteA2aAgent case, was
closed unmerged on 2026-08-24 in favour of #6859. The maintainer's reason is the more useful
outcome than the patch would have been: "the session state boundary across A2A is a general
transport property rather than an issue specific to SequentialAgent", and it is being addressed
centrally at the RemoteA2aAgent converter rather than in a deprecated component. That is a
confirmation of the finding and a decision about where the fix belongs — not an accepted fix, and
not a rejected report. Nothing here is merged.
| Agent | Institutional responsibility | Enforced capability |
|---|---|---|
| Orchestrator | Own investigation lifecycle, policy, routing, and escalation | Managed Agent Identity; Registry/Gateway egress; Firestore state |
| Access Auditor | Read the live policy and produce opaque, deterministic findings | Read-only IAM, Asset Inventory, and Recommender access |
| Escalation Agent | Deliver a validated count to the owning department | Findings API invocation; no IAM or Asset read role |
The Cloud Run services expose two security shapes deliberately:
- worker A2A origins on Cloud Run are network-reachable because the cross-region managed Gateway is not classified as Cloud Run internal traffic; every non-health request still requires a Secret-Manager-backed origin credential, while Agent Identity is admitted per destination by IAP at the Gateway;
- the findings endpoint is network-reachable but IAM-private. Only
escalation-agent-sahasroles/run.invoker; anonymous traffic is rejected by Cloud Run.
Bastion does not ask a model to decide whether a permission is safe.
- The Auditor reads production IAM under a read-only identity.
- Deterministic rules produce an opaque finding ID, category, department, and bounded score.
- Those findings cross the A2A boundary as a validated schema, not as prose a model retypes.
- The threshold and the department catalog are applied by a step that holds no model at all, and a gate refuses to escalate anything that step did not score.
- Missing or invalid risk is rejected; it can never become a quiet clear.
- A current, human-approved exception may suppress the same opaque finding until expiry.
- The Escalation Agent is itself a model-free worker: it delivers to every routed department and only those, and a second gate refuses to report the investigation complete unless that delivery was confirmed.
- Model Armor screens input — including tool results, not only prompts — and fails closed when unavailable.
- A deterministic post-model screen blocks principal, role, resource, and PII shapes.
- The receiver accepts only an allowlisted department, categories, deterministic summary, and SHA-256 idempotency key.
AuditPlugin is registered at every supported Runner seam — never called from inside an agent,
so there is no audit call site to forget or bypass. It records run, agent, model, and tool
starts/completions/failures plus Model Armor refusals. Records contain event type, outcome,
actor, investigation ID, invocation ID, argument names, model name, and exception
class — never argument values, prompts, responses, principal IDs, or exception messages.
The two ids answer different questions. invocation_id groups one agent run; ADK mints a fresh
one per run, so it stops at the A2A boundary. investigation_id is the durable event id, carried
to each worker as request metadata rather than as message content, so no model reads or restates
it — it is what makes a single investigation reconstructable across all three hops. It is
re-validated as a UUID where it is recorded, because it arrives from a peer.
Tool surfaces are fixed and repository-owned; no external text is ever interpolated into a tool declaration (ADR-007), and the Model Armor threshold is an explicit decision rather than a default (ADR-009).
The track asks for a scalable network of institutional agents. Bastion's scaling axis is deliberately not more agents — ADR-002 fixes the fleet at three, because a fourth agent with the same data access proves nothing that a third does not. The network scales on the axes that actually matter in an institution:
| Axis | How it grows | Cost of growth |
|---|---|---|
| Owning departments | A row in the catalog with its principal patterns; routing picks it up with no code change | One catalog entry |
| Throughput | Bounded Cloud Run autoscaling behind durable Eventarc delivery, with leases, retry, and a five-attempt dead letter | Configuration |
| A new institutional agent | A Registry Agent Card, an approved Gateway destination, an Agent Identity, and a deliberate change to agents/orchestrator/agent.py's SequentialAgent to invoke it |
Governance, not automatic discovery |
The third axis is deliberately the expensive one. Nothing in build_sub_agents() discovers or
invokes a newly registered agent on its own — a fourth agent requires the same review as the
three that exist today: Registry, Gateway policy, and identity, plus code review of exactly what
it can reach. That is the fixed trust boundary ADR-007 depends
on working as intended, not a scalability gap.
The measured proof that the first axis is real rather than decorative is evidence 11: on 2026-08-25 the deployed route read 62 live IAM bindings, produced 3 deterministic findings across 2 owning departments, and delivered 2 of them after policy suppressed an approved exception — without a model deciding who owns what, and with zero raw IAM fields crossing the model boundary. The binding count moves as the project's own IAM changes; evidence 09 recorded 52 on an earlier date and is retained as that measurement rather than overwritten.
Instance caps are deliberate. BASTION_MAX_INSTANCES=3 follows the organizers' own cost
guidance; it is a budget ceiling, not an architectural one. Bastion claims two Gateway
refusals rather than three, because two are what the managed control actually enforces — the
reasoning is recorded in ADR-003 rather than quietly dropped.
| Track requirement | Bastion evidence |
|---|---|
| Agents cataloged for cross-department use | Versioned Agent Cards publish owner, department, purpose, skill, classification, policy version, approval state, and health metadata. route_by_department() turns ownership into an enforced routing decision. |
| Context maintained across weeks of asynchronous work | Stable event/context IDs, Firestore inbox and leases, managed sessions and Memory Bank, expiring human-approved exceptions, retry/dead-letter handling, and idempotent notification keys. Observed across a real three-day gap spanning deployments and restarts. |
| Production data without violating compliance, sovereignty, or security policy | Read-only Cloud Asset Inventory. No raw member, role, resource, or binding ever crosses the model boundary — only opaque IDs, categories, departments, and bounded scores — so sovereign data is not merely kept in-region, it never leaves the process. Model Armor fails closed, output is screened before notification, logs carry no payload values, identities are separated, and state stays in the EU. |
All three diagrams are generated from one reviewed master each by scripts/render_diagrams.py,
which emits the light and dark variants and an animated GIF. The masters are gated: every
resource count in every diagram must equal the live capture, retired deployment claims are
blocked, and no animated packet may cross a component it is not entering or leaving. A diagram
that disagrees with the fleet fails CI. See docs/ARCHITECTURE.md for
trust-boundary detail and ADR-006 for what counts as observed.
The Eventarc boundary atomically admits an event before work starts. A running event owns a
bounded lease; concurrent duplicates receive 503 so Eventarc retains delivery. A dead worker's
expired lease can be reclaimed. Completed events are acknowledged without rerunning. Failed
events remain retryable. Delivery is capped at five attempts before the separate dead-letter
review subscription receives it.
Human escalation is independently idempotent. The tool derives
sha256(investigation_id:department) and the private receiver creates the document exactly once.
The same authorized request returns success with accepted=false on replay.
Operational objectives and alert mappings are in docs/OPERATIONS.md.
Sovereign data never reaches the model at all. Residency keeps regulated data inside a
region; Bastion does something stronger with the part that matters. That is why the deterministic
pre-pass exists: find_anomalies() decides what is a finding, and Gemini only writes the
sentence explaining one. A compliance product cannot answer "why was this flagged?" with "the
model thought so", and it cannot leak a binding it was never given —
ADR-001 and
ADR-005 record why real data and this framework,
and what each cost.
Where the infrastructure runs:
- Cloud Run, Firestore, Pub/Sub, and Eventarc run in
europe-north2. - Agent Runtime, Memory Bank, Gateway, Registry, Model Armor, and the retained audit bucket run
in
europe-west4. - Gemini 3.5 Flash uses Vertex AI
global. Global is not a regional-residency claim, and Bastion does not make one. The minimisation above is the control; the region is not asked to be.
The field-level inventory, retention, deletion, and processor boundaries are in docs/DATA_GOVERNANCE.md.
Requirements: Python 3.12, Google Cloud CLI, Application Default Credentials, and a project with the documented APIs and a Model Armor template.
The idempotent bootstrap accepts explicit regions and existing Memory/Runtime IDs, creates missing generated secrets without printing them, provisions identities and durable resources, builds one image, deploys the fleet, configures Gateway/Registry/Runtime, provisions observability, verifies the inventory, and runs the production smoke test.
.\infrastructure\bootstrap.ps1 `
-Project 'YOUR_PROJECT_ID' `
-MemoryAgentEngineId 'YOUR_MEMORY_ENGINE_ID' `
-RuntimeAgentEngineId 'YOUR_RUNTIME_ENGINE_ID'The prerequisite and least-privilege role list is in infrastructure/REQUIRED_GCP_ACCESS.md.
Useful independent gates:
$env:GCP_PROJECT_ID = 'YOUR_PROJECT_ID'
$env:GCP_REGION = 'europe-north2'
$env:AGENT_RUNTIME_REGION = 'europe-west4'
$env:BASTION_RUNTIME_AGENT_ENGINE_ID = 'YOUR_RUNTIME_ENGINE_ID'
python -m infrastructure.verify_fleet
python -m infrastructure.provision_observability
python -m infrastructure.smoke_test
python -m infrastructure.rollback # dry-run candidates only
python -m infrastructure.teardown # dry-run plan onlyApplying rollback requires an exact service and one of its two newest safe revisions. Applying
teardown additionally requires --confirm-project; by design it preserves Firestore, secrets,
Agent Runtime, and retained compliance logs.
ruff check .
ruff format --check .
mypy agents gateway identity registry runtime model_armor observability infrastructure
pytest tests --cov --cov-report=term-missing --cov-fail-under=100
python scripts/check_docs.py
python scripts/check_versions.py
python scripts/render_diagrams.py --check
python scripts/redact_evidence.py --check
node site/build.mjs
python scripts/check_site.pycheck_docs.py is not a linter. It fails the build when a document disagrees with the
repository: a wrong pillar or agent count, an ADR that exists but is unindexed, a badge quoting a
version that is not pinned, a hand-written status badge that would go stale, a retired deployment
claim, a diagram whose numbers no longer match the live capture, or an animated packet drawn
through a component. Every one of those rules exists because that exact mistake was made here
once.
CI holds no GCP key, and never will: infrastructure/provision_wif.sh federates GitHub
Actions through Workload Identity, so a short-lived OIDC token is exchanged for a short-lived
Google one. There is nothing to leak and nothing to rotate. The provider pins
assertion.repository to one repository — Google accepts any token GitHub's issuer signs, and
GitHub signs one for every repository on the platform, so that condition is the entire boundary.
The federated identity may deploy code and may not change authority: run.developer
rather than run.admin, and no role that can create a binding or alter Eventarc, Pub/Sub, the
Agent Registry, Firestore, Secret Manager, or the audit bucket. It may act as the three workload
identities and not as the approver identity, so a pipeline cannot approve the suppression of a
finding. The Deploy workflow is workflow_dispatch only, because deploying a live
access-governance fleet is a decision someone should make rather than a consequence of merging.
Configured 2026-08-22, not yet exercised. The pool, provider and grants exist, and the
boundary is verified twice over: the provider admits only assertion.repository == this
repository, and impersonation is bound to that same repository's principalSet rather than
to the pool, so an error in either is caught by the other. The deployer holds the five roles
above and cannot act as the approver identity. No deploy has run through this path yet, so
nothing here is claimed as observed.
agents/ three ADK agent definitions and managed Runtime entrypoint
gateway/ local policy contract and Cloud Run origin authentication
identity/ least-privilege workload manifest
model_armor/ fail-closed input and deterministic output screening
observability/ payload-free ADK audit plugin
registry/ department routing policy
runtime/ durable SQLite contract and Firestore production adapter
infrastructure/ bootstrap, deploy, Gateway, Runtime, smoke, rollback, teardown
scripts/ live-state capture, docs/version/media gates, diagram renderer
site/ static public evidence console; no operational backend or GCP calls
tests/ unit, integration, security, and load suites
assets/ brand, architecture, and redacted evidence
docs/ architecture, governance, operations, and ADRs
submission/ Devpost copy, checklist, and planning ledger
- Architecture · Data governance · Operations · Security
- Evidence index · Submission readiness · Observation backlog · Audit remediation ledger · Captured hackathon brief
- Architecture decisions — index: 001 real IAM · 002 three agents · 003 managed platform · 004 global Gemini · 005 ADK and A2A · 006 observable pillar proof · 007 tool poisoning · 008 human approval loop · 009 Model Armor threshold · 010 policy enforcement gate · 011 inbound screening covers tool results · 012 structured findings across A2A
Bastion is read-only with respect to IAM. It can identify, explain, suppress under a current human exception, and request review; it cannot modify or revoke a binding. Raw production policy dumps, credentials, endpoint secrets, and unredacted findings are prohibited from Git and published evidence.
It does not claim immutable audit storage, end-to-end EU model residency, legal certification, historical SLO attainment, or that a wall-clock week elapsed during testing. Those limits are part of the topology rather than disclaimers appended to it.
This is a hackathon submission, not supported production software. Licensed under the MIT License.