Skip to main content

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:

sag blocking a prompt-injection exfiltration attempt

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 check dry-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.read returns fixed in-memory text. tickets.create returns a fabricated ticket record with a random id. admin.rotate_key never 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. See src/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:

  1. Verify a signed delegated-identity JWT (RS256, algorithm allow-listed, no algorithm-confusion attack works against it).
  2. Reject missing, expired, malformed, wrong-signature, or wrong-audience tokens.
  3. Resolve the requested tool through a fixed, server-side registry. Tool code is never imported dynamically or named by the client.
  4. Validate arguments against a tool-specific Pydantic schema that rejects unknown fields.
  5. 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.
  6. Cross-check OPA's answer against the gateway's own fixed tool registry and fail closed on any disagreement, regardless of the decision.
  7. Return allow, deny, or approval-required.
  8. Sign a short-lived, replay-resistant execution grant and send it over mTLS to a narrow Worker Launcher. The Gateway never calls a handler.
  9. 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.
  10. 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)

Source distribution for secure-agent-gateway 0.1.0
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)

Table of built distributions (wheels) for secure-agent-gateway 0.1.0
File Interpreter ABI Platform Actions
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 log

Release 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 log

Security 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.

Select from 2 files

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 log

A 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.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page