Metadata-Version: 2.5
Name: secure-agent-gateway
Version: 0.1.0
Summary: Policy enforcement for AI agent tool calls: an MCP proxy plus a hardened delegated-identity reference gateway.
Project-URL: Homepage, https://github.com/jspillers10/secure-agent-gateway
Project-URL: Documentation, https://github.com/jspillers10/secure-agent-gateway/blob/main/docs/mcp-proxy.md
Project-URL: Issues, https://github.com/jspillers10/secure-agent-gateway/issues
Author: Jake Spillers
License: MIT
License-File: LICENSE
Keywords: ai-agents,llm,mcp,model-context-protocol,policy,security
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Requires-Python: <3.14,>=3.11
Requires-Dist: pydantic==2.13.4
Requires-Dist: pyyaml==6.0.3
Provides-Extra: dev
Requires-Dist: bandit==1.9.4; extra == 'dev'
Requires-Dist: docker==7.1.0; extra == 'dev'
Requires-Dist: mypy==2.3.1; extra == 'dev'
Requires-Dist: pip-audit==2.10.1; extra == 'dev'
Requires-Dist: pytest-asyncio==1.4.0; extra == 'dev'
Requires-Dist: pytest-cov==7.1.0; extra == 'dev'
Requires-Dist: pytest==9.1.1; extra == 'dev'
Requires-Dist: ruff==0.16.4; extra == 'dev'
Requires-Dist: setuptools==84.1.0; extra == 'dev'
Requires-Dist: types-pyyaml==6.0.12.20260906; extra == 'dev'
Provides-Extra: gateway
Requires-Dist: cryptography==50.0.0; extra == 'gateway'
Requires-Dist: fastapi==0.141.1; extra == 'gateway'
Requires-Dist: httpx==0.28.1; extra == 'gateway'
Requires-Dist: pyjwt==2.15.0; extra == 'gateway'
Requires-Dist: uvicorn[standard]==0.52.4; extra == 'gateway'
Provides-Extra: launcher
Requires-Dist: docker==7.1.0; extra == 'launcher'
Description-Content-Type: text/markdown

# 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

```bash
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:

```json
{
  "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:

```bash
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:

```bash
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](https://raw.githubusercontent.com/jspillers10/secure-agent-gateway/main/docs/assets/demo.gif)

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:

```bash
pip install -e .
python examples/injection-demo/agent.py
python examples/injection-demo/agent.py --protected
```

Details in [examples/injection-demo/](https://github.com/jspillers10/secure-agent-gateway/tree/main/examples/injection-demo).

## What the policy can do

```yaml
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](https://github.com/jspillers10/secure-agent-gateway/blob/main/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](https://github.com/jspillers10/secure-agent-gateway/blob/main/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

```bash
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/`](https://github.com/jspillers10/secure-agent-gateway/tree/main/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](https://github.com/jspillers10/secure-agent-gateway/blob/main/docs/architecture.md).
Full attacker-facing analysis: [docs/threat-model.md](https://github.com/jspillers10/secure-agent-gateway/blob/main/docs/threat-model.md).

### Architecture (summary)

```mermaid
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](https://github.com/jspillers10/secure-agent-gateway/blob/main/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.

```bash
python -m venv .venv
source .venv/bin/activate   # or .venv\Scripts\activate on Windows
pip install -e ".[gateway,dev]"
```

#### Run the tests

```bash
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

```bash
docker run --rm -v "$(pwd)/policy:/policy" openpolicyagent/opa:0.70.0 test /policy -v
```

(Or install the [`opa` CLI](https://www.openpolicyagent.org/docs/latest/cli/)
locally and run `opa test policy/ -v`.)

#### Run the full stack with Docker Compose

```bash
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](https://github.com/jspillers10/secure-agent-gateway/blob/main/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

```bash
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:

```bash
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:

```bash
# 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](https://github.com/jspillers10/secure-agent-gateway/blob/main/docs/threat-model.md) has a
corresponding automated test. To reproduce the full validation:

```bash
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](https://github.com/jspillers10/secure-agent-gateway/blob/main/docs/threat-model.md#security-assumptions)
and [docs/threat-model.md](https://github.com/jspillers10/secure-agent-gateway/blob/main/docs/threat-model.md#known-limitations). 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](https://github.com/jspillers10/secure-agent-gateway/blob/main/docs/milestone-1-implementation.md).

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