asante-secure-multi-agent
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@asante-secure-multi-agentIssue a $20 guest credit to reservation 7G2K9 for late check-in."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Asante Secure Multi-Agent Application
A production-style reference application for secure agentic operations at Asante Stays. It demonstrates how an LLM can propose actions without becoming the source of identity, authority, or execution trust.
Core principle: the model proposes; trusted runtime identity and delegation establish authority; Ruhusa independently authorizes; execution is revalidated; telemetry and evals prove what happened.
What this project demonstrates
Human authentication: OAuth-style Bearer JWT access tokens.
Workload identity: distinct trusted SPIFFE IDs for Supervisor, four specialists, and approval execution.
Delegated authorization: task-bound, scope-attenuated authority through Ruhusa 0.8.0.
Agent orchestration: OpenAI Agents SDK manager-style Supervisor invoking four bounded specialist agents.
Agent tool protocol: MCP over Streamable HTTP with authority hidden from model-visible schemas.
Execution security: trusted invocation provenance, execution fencing, revocation, and revalidation.
Observability: OpenTelemetry traces/metrics plus an OpenAI Agents tracing bridge.
Reliability: bounded known-safe retries, server-derived idempotency, and fail-closed unknown outcomes.
Authorization-aware caching: cached reservation reads never bypass live authorization.
Release safety: deterministic authorization, attack, reliability, disclosure, and agent-contract evals.
Least-privilege property operations: Reservations, Property Operations, Guest Support, and Service Recovery each have separate identity, tools, delegation, and Ruhusa policy.
Related MCP server: titmas-agent-action-gate
Architecture
Authenticated Human
|
OAuth Access Token
|
v
Operations Supervisor
(manager / final answer)
|
+---------------------+----------------------+
| | |
v v v
Reservations Agent Property Operations Guest Support
reservation.read maintenance.create guest.message.send
| | |
+---------------------+----------------------+
|
v
Service Recovery Agent
guest.credit.issue / guest.credit.request
|
v
Filtered MCP clients
|
v
Ruhusa
identity + delegation + policy + revalidation
/ \
/ \
Authorization-Aware Idempotent
Reservation Cache Credit Path
| |
v v
Reservation Provider Credit Provider
\ /
\ /
OpenTelemetry
|
deterministic evals
|
CI release gateSecurity invariants
The project treats these as release properties rather than informal expectations:
Agent orchestration is not authority delegation. The Supervisor may invoke a specialist, but only Ruhusa grants determine what that specialist may do.
Specialists are least-privilege workloads. Reservations, Property Operations, Guest Support, and Service Recovery have distinct SPIFFE IDs and distinct business capabilities.
MCP tool visibility is capability-scoped. Each specialist client sees only its allow-listed tools, and the server checks the trusted workload label before dispatch.
Model-visible MCP arguments cannot assert identity or grants. Task, principal, grant, workload, and cache authority stay server-side.
Ruhusa is checked immediately before protected execution or disclosure. A stale plan cannot bypass revocation.
Denied or approval-required credit actions produce zero side effects.
Unknown execution outcomes are never blindly retried.
A cache hit may save an external read, but it may never save the authorization check.
Unauthorized cached-data disclosure or cross-specialist privilege expansion is a release-blocking failure.
API documentation
When running locally, the application publishes three complementary interfaces:
Interface | URL | Purpose |
Swagger UI |
| Interactive authenticated testing |
ReDoc |
| Readable API contract/reference |
OpenAPI |
| Machine-readable API specification |
The operator-facing API is intentionally small:
Endpoint | Auth | Purpose |
| No | Service discovery and architecture metadata |
| No | Liveness probe |
| Bearer | Show the canonical authenticated human principal |
| Bearer | Run Supervisor -> least-privilege specialists -> MCP -> Ruhusa |
| Bearer | Inspect credits that actually reached the demo side effect |
| Bearer | Exercise the secured credit path without the LLM/MCP layer |
| Bearer | Exercise Ruhusa + authorization-aware cache directly |
| Bearer | Maintenance work orders created by the agent |
| Bearer | Outbound guest messages sent by the agent |
| Bearer | Durable human approval inbox |
| Bearer + | Approve and execute a pending credit |
| Bearer + | Deny a pending credit; nothing executes |
| Protocol endpoint | Streamable HTTP MCP transport; not a normal REST resource |
Ruhusa authorization denials are represented as domain outcomes such as status=blocked.
HTTP 401 is reserved for missing, invalid, or expired Bearer credentials. The approval
endpoints additionally return 403 without asante:approve, 404 for an unknown approval,
and 409 for an invalid state transition.
Release gate
CI is designed to fail closed. The deterministic gate requires:
minimum pass rate = 100%
maximum critical failures = 0
maximum unauthorized side effects = 0
maximum unauthorized disclosures = 0Run the same checks locally:
uv run ruff format .
uv run ruff check .
uv run pytest
uv run python -m asante_secure_multi_agent.evals --output eval-report.jsonDevelopment journey
The sections below preserve the implementation history and the security property introduced at each phase. The architecture above describes the system as it exists today.
Phase 9: least-privilege specialist agents
Phase 9 splits the broad Guest Support role into four stable business/security boundaries while keeping the Operations Supervisor responsible for the final operator-facing response. The Supervisor invokes specialists as agent tools, allowing one request to coordinate several specialists without transferring the whole conversation or sharing a broad MCP tool surface.
Operations Supervisor
-> Reservations: reservation.read
-> Property Operations: maintenance.create
-> Guest Support: guest.message.send
-> Service Recovery: guest.credit.issue / guest.credit.requestEach specialist has:
a distinct SPIFFE workload identity;
a capability-specific task-bound Ruhusa delegation chain;
a statically filtered MCP client tool list;
a trusted MCP workload label checked server-side; and
a Ruhusa policy that independently authorizes only that specialist's action.
This means prompts cannot turn role text into authority. For example, Guest Support cannot create maintenance work orders, Property Operations cannot message guests, Reservations cannot issue credits, and Service Recovery cannot read reservation records. The durable Approval Executor remains deterministic infrastructure rather than an LLM specialist.
Which layer enforces what
The MCP server binds each tool to its owning specialist's principal and delegation chain, so Ruhusa sees the tool's specialist rather than the caller. At runtime, a cross-specialist call is therefore stopped by two layers before Ruhusa is reached:
Client tool filter: each specialist's model is never shown another specialist's tools.
Server workload check: the MCP server rejects a call whose trusted
asante/workloadlabel does not own the tool, with zero side effects.
The workload label is set by trusted in-process client code, never by the model, but it is not cryptographically authenticated. Ruhusa's per-specialist policies and delegation chains are the backstop that keeps each tool within its specialist's scope (amount limits, actions, resources) and would deny a tool executed under the wrong chain. Making Ruhusa authorize the caller directly requires authenticated workload identity, which is the SPIRE/SVID roadmap item.
tests/test_specialist_runtime_boundary.py exercises layers 1 and 2 over real MCP calls;
tests/test_specialist_agents.py and the cross_agent_privilege_escalation_blocked eval
exercise the Ruhusa backstop.
Phase 8 product slice: property operations + human approval
Phase 8 moves the project from a secure-agent reference flow toward the real Asante Stays operating backend. At this phase, Guest Support held the broad product tool surface for reservation lookup, maintenance, messaging, and service recovery. Phase 9 subsequently splits those capabilities across least-privilege specialists.
Guest / operator issue
-> Operations Supervisor
-> Guest Support
-> get_reservation
-> create_maintenance_request
-> send_guest_message
-> service recovery
-> <= $25: issue_guest_credit
-> $25-$100: request_guest_credit -> human approval inbox
-> > $100: deny / escalateThe approval path deliberately separates authority to request a credit from authority to execute it. The model can create a pending approval request but cannot mark it approved or invoke the approved-credit executor.
Approval and denial endpoints additionally require the validated human token to
contain the asante:approve scope, so the operator who requests work does not
automatically gain manager approval authority.
Agent
-> MCP request_guest_credit
-> Ruhusa guest.credit.request
-> SQLite approval record (pending)
Authenticated human
-> POST /operations/approvals/{id}/approve
-> durable approval decision
-> trusted approval-executor workload
-> Ruhusa guest.credit.issue.approved
-> idempotent credit provider writeA denial never executes the credit. Repeating an approved execution is idempotent, and the approval record survives process restarts when the configured SQLite path is persistent.
Operations dashboard endpoints
GET /operations/work-orders
GET /operations/messages
GET /operations/approvals
POST /operations/approvals/{approval_id}/approve
POST /operations/approvals/{approval_id}/denyFor local development, approval state defaults to .asante/approvals.db and can
be moved with ASANTE_APPROVAL_DB_PATH. Maintenance and message providers remain
in memory until the production-backend roadmap item.
New MCP tools
create_maintenance_request(reservation_id, category, urgency, description)
send_guest_message(reservation_id, message)
request_guest_credit(reservation_id, amount, reason)As with the earlier MCP boundary, task IDs, principals, delegation chains, grant IDs, and approval-verification flags are never model-visible tool arguments.
Phase 7 vertical slice: authorization-aware caching
Phase 7 adds a protected read capability, get_reservation, and an in-memory TTL
cache that is deliberately placed after Ruhusa authorization and execution-time
revalidation.
Reservations
-> MCP get_reservation
-> trusted task lookup
-> Ruhusa authorization
-> Ruhusa revalidation
-> authorization-aware cache
-> HIT: return cached reservation
-> MISS: reservation provider -> cache -> returnThe central invariant is:
A cache hit may save an external read, but it may never save the authorization check.
The cache stores reservation data, not Ruhusa ALLOW decisions. A revoked grant is
therefore denied before cache.get() is reached, even if the requested reservation
is still physically present in cache.
Capability-specific delegation
Credit and reservation-read authority use separate delegation chains. The credit
chain keeps its $100 -> $25 numeric attenuation, while the reservation-read chain
contains only reservation.read. This avoids applying credit-specific argument
constraints to unrelated read actions.
Cache-key design
The Phase 7 key is derived from stable resource semantics and a schema version:
reservation-read-v1
reservation.read
reservation:<id>The resulting key is hashed, so raw reservation IDs do not appear in the key. Task IDs and grant IDs are intentionally excluded because authorization is checked on every read; including ephemeral IDs would prevent useful cross-request cache hits. If the representation later becomes tenant-, role-, or user-specific, that security context must become part of the cache partition/key.
Cache security evals
The deterministic gate now includes cache cases for:
first authorized read -> cache miss;
repeated authorized read -> cache hit;
different resource -> cache miss;
revoked grant after cache population -> deny with no disclosure;
cross-task replay -> deny with no disclosure;
cache schema-version change -> stale key not reused; and
MCP reservation schema -> no task/grant/principal/cache authority exposed to the model.
The release thresholds now include zero unauthorized disclosures in addition to zero unauthorized side effects.
Local cache test
After authorizing in Swagger, call:
GET /demo/reservations/R-3001The first response should report "cache": "miss"; the next authorized call should
report "cache": "hit" while still creating a fresh Ruhusa task/delegation and
revalidating authority.
Phase 6 vertical slice: reliability + eval-gated CI
Phase 6 turns the security/reliability properties from documentation into a release gate. CI does not need an OpenAI API key: it evaluates deterministic invariants that must never depend on model behavior.
PR / push
-> locked dependency sync
-> ruff format/lint
-> pytest
-> deterministic release evals
-> normal authorization cases
-> delegation attack cases
-> MCP authority-boundary checks
-> idempotency/retry checks
-> agent safety instruction contracts
-> PASS only when every gate is greenThe release thresholds are intentionally strict:
deterministic pass rate: 100%;
critical eval failures: 0;
unauthorized side effects: 0.
Run the same gate locally:
uv run python -m asante_secure_multi_agent.evals --output eval-report.jsonThe command exits non-zero when the release gate fails, so the same behavior can block a pull request in GitHub Actions.
Reliability policy
The protected credit path now distinguishes retry safety:
TransientCreditProviderError: the adapter knows no side effect happened, so a small bounded retry budget may be used;UnknownOutcomeCreditProviderError: execution may have happened, so the application does not blindly retry and Ruhusa is marked unknown;repeated identical logical credits in the same trusted task use a server-derived idempotency key and are deduplicated.
The model never supplies the idempotency key. It is derived from trusted task ID, reservation, action, and amount.
Deterministic attack/eval catalog
The Phase 6 gate includes cases for:
small allowed credit;
approval-required action produces no side effect;
policy-denied action produces no side effect;
delegated-limit bypass attempt;
widened child-grant attack;
cross-task grant replay;
MCP schema authority injection;
duplicate logical execution/idempotency;
known-safe transient retry;
unknown-outcome no-retry behavior; and
Supervisor/specialist safety-instruction contracts.
A future live-model eval suite can measure model behavior separately. The CI release gate remains deterministic so a security invariant never becomes a probabilistic test.
Phase 5 vertical slice: end-to-end agent observability
Phase 5 adds OpenTelemetry without changing who is trusted or what Ruhusa allows.
HTTP /agent/run
-> Bearer token verification
-> human -> Supervisor -> specialist delegation
-> OpenAI Agents workflow / handoff / generation structure
-> Streamable HTTP MCP
-> trusted task lookup
-> Ruhusa admission
-> Ruhusa execution-time revalidation
-> protected credit side effectThe API returns a trace_id on POST /agent/run, POST /demo/credits, and
GET /auth/whoami so an application result can be correlated with its trace.
What is traced
Phase 5 records structural and security metadata such as:
authenticated vs rejected identity attempts;
delegation depth and bounded credit limits;
Supervisor / specialist agent runtime structure;
MCP tool name and outcome;
Ruhusa admission and revalidation effects;
policy ID when one matched;
protected side-effect outcome; and
FastAPI request latency/status.
The application intentionally does not copy prompts, completions, tool arguments/results, reservation IDs, Bearer tokens, or human subject IDs into custom OpenTelemetry span attributes. OpenAI Agents native tracing remains enabled separately unless you disable it using the Agents SDK configuration.
OpenAI Agents -> OpenTelemetry bridge
The Agents SDK already traces agent runs, model generations, handoffs and tool calls. Phase 5 registers an additional tracing processor that mirrors the SDK's structure into the current OpenTelemetry trace. This preserves the native OpenAI trace destination while giving the application a vendor-neutral SRE trace that also contains HTTP, MCP, Ruhusa and execution spans.
MCP trace propagation
The MCP client injects W3C trace context into the Streamable HTTP headers. It
also injects current trace context into hidden MCP _meta alongside the trusted
task reference. That metadata is not part of the model-visible tool schema. The
MCP server extracts it before creating the asante.mcp.issue_guest_credit span.
Metrics
Phase 5 emits low-cardinality metrics:
asante.authorization.decisions
asante.authorization.duration
asante.mcp.tool.calls
asante.credit.executionsAuthorization metrics distinguish admission from execution-time revalidation. They do not carry operator, reservation or prompt data.
Exporters
Local development defaults to console output:
export ASANTE_OTEL_EXPORTER=consoleTo keep instrumentation active without exporting:
export ASANTE_OTEL_EXPORTER=noneTo send OTLP/HTTP to an OpenTelemetry Collector or compatible backend:
export ASANTE_OTEL_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318The OpenTelemetry SDK/exporters use the standard OTEL_EXPORTER_OTLP_*
environment variables for further OTLP configuration.
Security chain preserved from Phases 1-4
Authentication
Who is this human/workload?
Delegation
What authority was passed to this workload?
MCP
How does the agent reach the capability?
Authorization
May this workload perform this exact action now?
Observability
What happened, where did time go, and which security boundary stopped it?Ruhusa remains the authorization boundary. OpenTelemetry observes decisions; it does not influence them.
Run locally
Requires Python 3.12+ and uv.
Create a local environment file, replace OPENAI_API_KEY=replace-me, and load
the file into the shell that starts the API:
cp .env.example .env
set -a
source .env
set +a
uv sync
export ASANTE_OTEL_EXPORTER=console
uv run pytest
uv run uvicorn asante_secure_multi_agent.main:app --reloadIn a second terminal, load the same environment file before creating a development access token:
set -a
source .env
set +a
TOKEN=$(uv run python -m asante_secure_multi_agent.identity.dev_token claire)
printf '%s\n' "$TOKEN"For the human approval endpoints, create a manager token with explicit approval
authority (in the same terminal, after loading .env):
MANAGER_TOKEN=$(uv run python -m asante_secure_multi_agent.identity.dev_token manager \
--scope "asante:operate asante:approve")
printf '%s\n' "$MANAGER_TOKEN"The API and token generator must use the same ASANTE_DEV_JWT_SECRET, issuer,
and audience. The token command does not load .env automatically. Development
tokens expire after 30 minutes, so generate a new one after expiration.
Test with FastAPI Swagger
Open http://127.0.0.1:8000/docs, click Authorize, and paste only the token
value without the Bearer prefix. Start with GET /auth/whoami; a 200
response confirms that authentication is configured correctly. A 401 with
Invalid or expired access token usually means the token expired or it was
generated with different JWT settings than the running API.
The POST /agent/run schema intentionally contains only a natural-language
message. Use this reservation lookup example:
{
"message": "Look up reservation R-3001 and summarize its status."
}Use this example for an allowed credit:
{
"message": "Issue a $20 credit to reservation R-3001 because of a Wi-Fi outage."
}Use this example to confirm that delegated authority is enforced:
{
"message": "Issue a $40 credit to reservation R-3001 because of an extended Wi-Fi outage."
}The $20 credit should be issued. The $40 credit is above the Service Recovery
agent's $25 direct limit, so the agent should request human approval instead:
GET /operations/approvals shows a pending request and no credit is issued.
A successful agent response also includes the Ruhusa task ID and an
OpenTelemetry trace ID.
To test the full Phase 9 workflow, send:
{
"message": "Guest R-3001 says there has been no hot water for two hours. Verify the reservation, create an urgent maintenance request, send the guest an update, and request a $75 service-recovery credit."
}Then check GET /operations/work-orders, GET /operations/messages, and
GET /operations/approvals: you should see an open work order, a sent message,
and a pending $75 approval, but no $75 entry in GET /demo/credits.
Re-authorize with MANAGER_TOKEN and call
POST /operations/approvals/{approval_id}/approve with
{"note": "Approved after reviewing the outage duration."}. Only then does the
credit appear in GET /demo/credits.
To test authorization without making an OpenAI request, use
POST /demo/credits with:
{
"reservation_id": "R-3001",
"amount": 20,
"reason": "Wi-Fi outage"
}To test authorization-aware caching, call
GET /demo/reservations/R-3001 twice. The first response should contain
"cache": "miss" and the second should contain "cache": "hit".
The normal learning cases remain:
$20credit -> allowed and executed;$40credit -> above the$25direct limit, so it becomes a pending approval request;$150credit -> above the$100approval ceiling, so it is denied outright;/demo/credits-> direct authenticated Ruhusa diagnostic path.
Phase 5 tests
Phase 5 preserves all identity, delegation, MCP and execution regressions and adds tests for:
W3C
traceparentinjection and trace-ID correlation;OpenAI Agents span mirroring into OpenTelemetry;
parent/child trace structure between the application and agent runtime; and
preventing prompt/tool content from entering custom OTel attributes.
Roadmap
Prove secure tool execution with Ruhusa.Add real supervisor -> specialist delegation grants.Move the guest-credit tool surface to MCP.Add authenticated human identity and trusted workload identity.Add OpenTelemetry traces and security metrics.Add agent evals and authorization attack tests to CI.Add authorization-aware caching with revocation-safe reads.Add durable human approval workflow plus maintenance and guest messaging.Add least-privilege specialist agents for Reservations, Property Operations, Guest Support, and Service Recovery.Add cleaning / turnover workflows.
Add secure access / door-code operations.
Replace in-memory stores with production backends/shared task state.
Replace the in-memory cache with Redis while preserving the same authorization-before-cache invariant.
Add real PMS / booking-channel integration.
Split MCP/agent workloads into deployable services.
Replace static SPIFFE assignment with SPIRE/SVID verification.
This server cannot be deployed
Maintenance
Related MCP Connectors
- gatewayOAuthai.sealgate
MCP gateway with runtime security policy, tool-call-level control, and audit of agent actions.
Zero-secret MCP gateway for AI agents: risk-scored, audited calls with human-in-the-loop approval.
Runtime permission, approval, and audit layer for AI agent tool execution.
Tenant-scoped control layer for agent-to-agent systems: governed routing, approvals, evidence.
Related MCP Servers
AlicenseNot gradedqualityDmaintenanceRuntime permission, approval, and audit governance for AI agent tool execution, enabling human oversight of risky actions via an MCP server.1MIT- AlicenseBqualityBmaintenanceAn MCP server that enforces deterministic authorization boundaries for AgentTeams workflows by verifying evidence and policy, returning ALLOW, BLOCK, or REQUIRE_APPROVAL decisions before actions are executed.6Apache 2.0
- AlicenseAqualityDmaintenanceEnables policy-governed MCP interactions with deterministic authorization, tenant isolation, minimized PII exposure, and human approval gates for sensitive mutations, while producing structured audit events.3MIT
- AlicenseNot gradedqualityBmaintenanceEnables MCP-compatible AI agents to safely act on business backends by enforcing per-agent permissions, autonomy thresholds, human approval with review-and-edit, and full audit trails.MIT