Secure Agent Gateway
A policy firewall for AI agent tool calls. Put sag between your MCP client
(Claude Desktop, Claude Code, Cursor) and any stdio MCP server. Every tool
call is checked against a YAML policy before it reaches the server, and is
then allowed, blocked, or held until you approve it.
The policy runs outside the model, so injected instructions cannot argue with it.
Quickstart
pipx install secure-agent-gateway
sag init # writes ./sag-policy.yaml with safe defaults
Wrap a server in your Claude Desktop config
(~/Library/Application Support/Claude/claude_desktop_config.json on macOS,
%APPDATA%\Claude\claude_desktop_config.json on Windows). This example
puts the gateway in front of the official filesystem server:
{
"mcpServers": {
"filesystem": {
"command": "sag",
"args": [
"mcp", "-c", "/Users/you/sag-policy.yaml", "--",
"npx", "-y", "@modelcontextprotocol/server-filesystem", "/Users/you/projects"
]
}
}
}
Restart Claude Desktop and ask Claude to read a .env file in that folder.
The call is blocked before it reaches the server, and Claude is told why.
If Claude Desktop cannot find sag, use the full path from which sag
(macOS/Linux) or where sag (Windows). On Windows, write paths in the JSON
with doubled backslashes (C:\Users\you\projects).
Claude Code:
claude mcp add filesystem -- sag mcp -c ~/sag-policy.yaml -- \
npx -y @modelcontextprotocol/server-filesystem ~/projects
Cursor uses the same JSON shape as Claude Desktop, in ~/.cursor/mcp.json.
If you use uv, install with uv tool install secure-agent-gateway. Avoid uvx sag, which would fetch an unrelated package named sag.
When a call is held for approval:
sag pending # what is waiting, with credentials masked
sag approve 3f9c0a1b2e
Then tell the agent to retry. The approval covers that exact call, once.
Demo
A scripted agent reads a file containing a hidden prompt injection and tries to send your AWS keys to an attacker. First without the gateway, then with it:
Without the gateway, the key is read and posted. With it, the .env read is
blocked by a path rule, run_shell is hidden and blocked, and when the agent
finds the key in a backup file no rule covers, the secret scan stops it at
the http_post. Run it yourself from a clone:
pip install -e .
python examples/injection-demo/agent.py
python examples/injection-demo/agent.py --protected
Details in examples/injection-demo/.
What the policy can do
version: 1
default: ask # for tools no rule matches
secrets: {scan: true, action: deny}
tools:
- name: "*shell*" # glob over tool names; first match wins
action: deny # also hidden from tools/list
- name: "read_*"
action: allow
deny_args:
"*path*": ["*.env", "*/.ssh/*", "*.pem"]
- name: http_post
action: allow
allow_args:
url: ["https://api.github.com/*"]
- name: "write_*"
action: ask # held until `sag approve <id>`
- allow / deny / ask per tool, with glob tool names and a default.
- deny_args / allow_args per argument. Deny rules also catch case,
.., backslash, and Windows trailing-dot variants. Allow rules are strict. - Secret detection in every argument: AWS keys, private key blocks, GitHub, GitLab, Slack, OpenAI, Anthropic, Google, and Stripe tokens, and JWTs. This is what stops a hijacked agent from pasting a credential it already saw into an outbound call.
- Single-use approvals bound to the tool and a hash of its exact arguments. Changing one character needs a new approval.
- Redacted audit log at
~/.sag/audit.jsonl: decisions, rules, and argument hashes. Raw argument values are never written. sag checkdry-runs a call against a policy, for testing policies in CI.
Full reference, including evaluation order and the security model: docs/mcp-proxy.md.
Limits
The proxy governs tools/call over stdio. It does not inspect tool results,
it cannot recognize a secret the model has encoded first (base64, split
across calls), and it trusts the upstream server to name its tools honestly.
Approvals are only as strong as the separation between the agent and your
terminal, so do not give the agent a shell. See
docs/mcp-proxy.md#security-model-and-limits.
Roadmap
- Streamable HTTP transport, for remote MCP servers.
- Route proxy decisions through the same OPA policy the reference deployment uses, so one policy governs both.
- Scan tool results before they reach the model.
Development
python -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -e ".[gateway,dev]"
pytest
ruff check src tests scripts examples && mypy src && bandit -r src -c pyproject.toml
Hardened reference deployment
The proxy is the quick way to protect an agent you already run. This repository started as a research reference for a stricter architecture: delegated identity, OPA policy, and per-call disposable workers. The original description follows.
A security-engineering vertical slice demonstrating how AI agents can invoke tools through delegated identity, least-privilege authorization, policy enforcement, approval controls, argument validation, and auditable execution.
It is the enforcement layer an LLM-driven agent sits behind, with no model of its own: the boundary that decides, independent of anything the agent claims about itself, whether a requested tool call is allowed to happen at all.
All tools in this repository are inert mocks.
documents.readreturns fixed in-memory text.tickets.createreturns a fabricated ticket record with a random id.admin.rotate_keynever contacts a key management system and never rotates a real credential; it always returns a simulated result. No tool performs shell execution, network I/O, or file I/O. Seesrc/secure_agent_gateway/tools_impl/.
What this demonstrates
POST /v1/tool-invocations is the one endpoint in this slice, and every
request through it does all of the following, in order, failing closed at
every step:
- Verify a signed delegated-identity JWT (RS256, algorithm allow-listed, no algorithm-confusion attack works against it).
- Reject missing, expired, malformed, wrong-signature, or wrong-audience tokens.
- Resolve the requested tool through a fixed, server-side registry. Tool code is never imported dynamically or named by the client.
- Validate arguments against a tool-specific Pydantic schema that rejects unknown fields.
- Non-consumingly validate any presented approval, then ask Open Policy Agent (which owns the mapping from tool to required scope, risk, and approval) for a decision. The client cannot set or override the risk level; there is no field for it, and the gateway never even sends OPA a pre-resolved one.
- Cross-check OPA's answer against the gateway's own fixed tool registry and fail closed on any disagreement, regardless of the decision.
- Return
allow,deny, orapproval-required. - Sign a short-lived, replay-resistant execution grant and send it over mTLS to a narrow Worker Launcher. The Gateway never calls a handler.
- Let the Launcher select a registry-pinned image and fixed entry point, create a fresh hardened Docker Worker, deliver one bounded stdin request, verify its per-run signed result, and destroy it.
- Emit one or more redacted, structured audit events per request: hashes of arguments and results only. Raw values, approval ids, and credentials are never recorded.
Full request/decision flow: docs/architecture.md. Full attacker-facing analysis: docs/threat-model.md.
Architecture (summary)
flowchart LR
Agent(["AI Agent"]) -- "Bearer: delegated-identity JWT" --> Gateway
subgraph Gateway["Secure Agent Gateway"]
direction LR
Identity["Verify identity<br/>(RS256, sub==agent_id)"] --> Registry["Resolve tool<br/>(fixed registry)"]
Registry --> Validate["Validate args<br/>(extra=forbid)"]
end
Validate -- "{agent_id, scopes, tool,<br/>approval_state}" --> OPA["Open Policy Agent<br/>(owns tool -> scope/risk/approval)"]
OPA -- "decision + metadata" --> CrossCheck["Cross-check vs. registry"]
CrossCheck -- "only on allow +<br/>match + atomic consume" --> Grant["Signed execution grant"]
Grant -- "mTLS" --> Launcher["Worker Launcher<br/>(only Docker socket holder)"]
Launcher --> Worker["Fresh hardened Worker<br/>(no network)"]
Worker -- "per-run signed result" --> Launcher
Launcher --> Gateway
Gateway --> Audit[("Redacted audit log")]
See docs/architecture.md for the full sequence diagram, trust-boundary breakdown, the two-phase approval flow, the Docker network topology, and the specific mechanisms that prevent risk-level override and JWT algorithm confusion.
Local setup
Requires Python 3.11 to 3.13 and, for the full integration path, Docker Compose.
python -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -e ".[gateway,dev]"
Run the tests
pytest -v --cov=secure_agent_gateway --cov-report=term-missing
ruff check src tests
mypy src
bandit -r src -c pyproject.toml
Run the Rego policy tests directly
docker run --rm -v "$(pwd)/policy:/policy" openpolicyagent/opa:0.70.0 test /policy -v
(Or install the opa CLI
locally and run opa test policy/ -v.)
Run the full stack with Docker Compose
python scripts/generate_dev_keys.py # writes ./devkeys/, gitignored, never committed
docker compose --profile worker-build build worker-image
docker compose up -d --build
python scripts/smoke_test.py # Gateway -> OPA -> Launcher -> fresh Workers
python scripts/verify_worker_hardening.py
docker compose down
The gateway listens on http://localhost:8088 (mapped from container port
8000: 8000 is a common local dev port, so compose remaps it to avoid
collisions; change the mapping in docker-compose.yml if you'd rather use
8000). OPA and the Launcher have no published ports; the Gateway reaches them
over mutually authenticated TLS on an isolated Docker network with no internet
egress. Disposable Workers have no network attachment at all; see
docs/architecture.md#docker-network-topology
for why, and scripts/verify_opa_hardening.py for how to still query it
directly for debugging.
Manually exercise the API
python scripts/generate_dev_keys.py
docker compose --profile worker-build build worker-image
docker compose up -d --build
TOKEN=$(python scripts/mint_demo_token.py --scope documents.read)
curl -s http://localhost:8088/v1/tool-invocations \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"tool": "documents.read", "arguments": {"document_id": "doc-001"}}' | python -m json.tool
Try a high-risk tool without approval:
ADMIN_TOKEN=$(python scripts/mint_demo_token.py --scope admin.rotate_key)
curl -s http://localhost:8088/v1/tool-invocations \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"tool": "admin.rotate_key", "arguments": {"key_id": "key-1", "reason": "rotate"}}' | python -m json.tool
# -> {"decision": "approval-required", ...}
Grant and use an approval:
# Note: there is no `granted_by` field; the approver identity recorded
# on the grant always comes from server-side APPROVER_IDENTITY, never
# from the request body (see docs/architecture.md#two-phase-approval-flow).
APPROVAL_ID=$(curl -s http://localhost:8088/v1/approvals \
-H "X-Approver-Key: dev-only-approver-key-not-a-secret" \
-H "Content-Type: application/json" \
-d '{"tool": "admin.rotate_key", "arguments": {"key_id": "key-1", "reason": "rotate"}, "agent_id": "agent-demo-001", "delegated_user_id": "user-demo-001"}' \
| python -c "import json,sys; print(json.load(sys.stdin)['approval_id'])")
curl -s http://localhost:8088/v1/tool-invocations \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tool\": \"admin.rotate_key\", \"arguments\": {\"key_id\": \"key-1\", \"reason\": \"rotate\"}, \"approval_id\": \"$APPROVAL_ID\"}" | python -m json.tool
# -> {"decision": "allow", "result": {"status": "simulated_rotation_recorded", ...}}
Reproduction: verifying the security claims
Every claim in the threat model has a corresponding automated test. To reproduce the full validation:
pip install -e ".[gateway,dev]"
pytest -v # 176 tests, includes every required scenario below
ruff check src tests scripts && mypy src && bandit -r src -c pyproject.toml
pip-audit # dependency vulnerability scan
docker run --rm -v "$(pwd)/policy:/policy" openpolicyagent/opa:0.70.0 test /policy -v
docker run --rm -v "$(pwd)/policy:/policy" openpolicyagent/opa:0.70.0 check /policy
python scripts/generate_dev_keys.py
docker compose up -d --build
python scripts/smoke_test.py # host -> gateway, end-to-end
docker run --rm --network secure-agent-gateway_internal_net \
-v "$(pwd)/scripts:/scripts:ro" --entrypoint python secure-agent-gateway-gateway \
/scripts/verify_opa_hardening.py # internal_net -> opa directly
docker compose down
Required scenarios and where each is tested:
| Scenario | Test |
|---|---|
| Valid read request | tests/test_tool_invocation.py::test_valid_read_request_allowed |
| Missing token | test_missing_token_rejected (both files) |
| Expired token | tests/test_token_verification.py::test_expired_token_rejected |
| Wrong issuer | ::test_wrong_issuer_rejected |
| Wrong audience | ::test_wrong_audience_rejected |
| Invalid signature | ::test_invalid_signature_rejected |
| Missing delegated scope | tests/test_tool_invocation.py::test_missing_delegated_scope_denied |
| Scope escalation attempt | ::test_scope_escalation_attempt_denied |
| Unknown tool | ::test_unknown_tool_rejected |
| Unexpected argument | ::test_unexpected_argument_rejected |
| Client-supplied risk override | ::test_client_supplied_risk_override_rejected |
| Policy-service failure | ::test_policy_service_failure_fails_closed |
| Approval required | tests/test_approvals.py::test_approval_required_when_none_supplied |
| Approval replay | ::test_approval_replay_denied |
| Approval for different arguments | ::test_approval_for_different_arguments_denied |
| Approval for different identity | ::test_approval_for_different_identity_denied |
| Audit redaction | tests/test_audit.py::test_audit_redacts_raw_arguments |
| Audit entry for denial | ::test_audit_entry_for_denial_is_distinguishable_from_allow |
| Mock tool exception fails safely | tests/test_tool_invocation.py::test_mock_tool_exception_fails_safely, ::test_unexpected_tool_exception_fails_safely |
| Compromised gateway can't weaken policy (spoofed scope/risk) | policy/gateway/authz_test.rego::test_ignores_spoofed_required_scope_and_risk, tests/test_approvals.py::test_fake_policy_client_ignores_spoofed_scope_metadata, scripts/verify_opa_hardening.py (live OPA) |
| Registry-policy metadata mismatch fails closed | tests/test_tool_invocation.py::test_registry_policy_mismatch_fails_closed |
| Approval survives a policy outage / a policy denial | tests/test_approvals.py::test_approval_survives_policy_outage, ::test_approval_survives_policy_denial |
| Only one of two competing consumers wins a race | ::test_approval_race_only_one_consumer_wins, ::test_consume_race_lost_denies_at_api_level |
| Approval expiring between validation and consumption | ::test_approval_expiring_between_validate_and_consume_fails_closed |
sub/agent_id mismatch rejected |
tests/test_token_verification.py::test_sub_agent_id_mismatch_rejected |
| Unsupported JWT algorithm rejected at startup | tests/test_config.py::test_load_settings_rejects_unsupported_jwt_algorithm |
Client-supplied granted_by rejected |
tests/test_approvals.py::test_create_approval_ignores_client_supplied_granted_by |
| Approval lifecycle audited (created / rejected / consumed / binding failure) | tests/test_audit.py::test_approval_creation_audited_and_redacted, ::test_approval_rejection_audited, ::test_approval_consumption_audited, ::test_approval_replay_binding_failure_audited |
| OPA management API rejects policy mutation | policy/system/authz_test.rego, scripts/verify_opa_hardening.py (live OPA) |
Plus algorithm-confusion coverage
(test_algorithm_confusion_none_rejected,
test_algorithm_confusion_hs256_with_public_key_rejected), fail-closed
configuration loading (tests/test_config.py), and approver-credential
enforcement on POST /v1/approvals (tests/test_approvals.py).
Project layout
src/secure_agent_gateway/
identity/ JWT verification -> AgentIdentity (RS256-only, sub==agent_id)
registry/ fixed tool registry + per-tool argument schemas
risk.py risk-level enum (values are resolved by OPA)
policy/ OPA HTTP client (fail-closed on any error or schema mismatch)
approvals/ single-use approval record store (validate, then atomic consume)
audit/ redacted structured audit events (tool invocations + approvals)
execution/ closed envelopes, canonical hashing, signing, Launcher client, audit
launcher/ narrow Docker execution service, registry, replay guard
worker/ one-shot isolated Worker protocol/runtime
workload/ development workload-certificate validation
tools_impl/ inert mock tool handlers
proxy/ MCP stdio proxy: YAML policy, secret scan, approvals, audit
cli.py the `sag` command line
api/ FastAPI routes, dependencies, request/response schemas
policy/gateway/ Rego authorization policy (owns tool -> scope/risk/approval) + opa test suite
policy/system/ OPA's own read-only management-API policy + opa test suite
tests/ pytest suite (unit + API-level)
scripts/ key generation, smoke/OPA/Worker checks, latency measurement, demo GIF
examples/ prompt-injection demo for the MCP proxy
docs/ architecture, threat model, research roadmap, Milestone-1 report
Security assumptions and known limitations
Summarized in docs/threat-model.md and docs/threat-model.md. In short: the approval store is in-memory and single-process (not production-durable), the approver credential is a static placeholder (not a real approver identity system), there's no rate limiting, workload certificates are development fixtures, replay state is process-local, the Gateway retains edge-network egress, and the Launcher/Docker host boundary remains a high-value trust assumption. See the Milestone-1 implementation report.
Roadmap
Beyond this vertical slice:
- Stronger isolation comparison: evaluate gVisor or a microVM against the current disposable Docker boundary without changing the signed protocol.
- Untrusted tool output: treat a tool's return value as attacker-influenced input once real, non-inert tools exist (e.g. a document-fetch tool returning content from an external source), with output scanning/sanitization before it reaches an agent or LLM.
- Production workload identity: replace local development CAs with automated enrollment, rotation, revocation, and protected workload keys.
- Adversarial evaluations: a red-team harness that scripts prompt- injection-style and policy-bypass attempts against a live gateway (not just unit tests of individual controls) and tracks pass/fail over time.
Metadata
Release files for secure-agent-gateway 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| secure_agent_gateway-0.1.0.tar.gz | 517.8 kB | With provenance | Details for secure_agent_gateway-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| secure_agent_gateway-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for secure_agent_gateway-0.1.0-py3-none-any.whl |
Total release size: 591.3 kB
Release files / secure_agent_gateway-0.1.0.tar.gz
| Download URL | secure_agent_gateway-0.1.0.tar.gz |
|---|---|
| Size | 517.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3f9d292a21020bb4fc49f6b0f04530d6218dfa72cf6d018af63a41045ee074d3
|
|
BLAKE2b-256 checksum How to use checksums |
f8c2296327e612a8c4311f65b8d4ddd10223bcd613f83f84062594b9608efd2b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 6, 2026.
Transparency logRelease files / secure_agent_gateway-0.1.0-py3-none-any.whl
| Download URL | secure_agent_gateway-0.1.0-py3-none-any.whl |
|---|---|
| Size | 73.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1964ebb4ce77617beae007210625b0bb9ee605d87e17d5f249c6c1d9daeb2a40
|
|
BLAKE2b-256 checksum How to use checksums |
18c341f76079e8c5c1f49d53c40a33482ac4363d310c335195cbf42b9e014eee
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 6, 2026.
Transparency logSecurity for secure-agent-gateway 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for secure_agent_gateway-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 6, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.