Your agent spawned a sub-agent and handed it the same API key. That sub-agent can now deploy to production, read the payments database, and merge to main.
Pigeon stops that. You hand the child a Pigeon Pass: a narrowed, signed credential for what it may do, not a copy of everything you can do.
Python 3.12 or newer.
git clone https://github.com/pigeonlabsHQ/pigeon.git
cd pigeon
pip install .from pigeon import grant, verify
authority = grant(
subject="agent:deployer",
capabilities=["deploy"],
resources=["environment:staging"],
)
allowed = verify(authority, action="deploy", resource="environment:staging")
assert allowed.allowed
denied = verify(authority, action="deploy", resource="environment:production")
assert not denied.allowed
assert denied.reason_code == "RESOURCE_NOT_ALLOWED"
print(denied.reason_code, denied.message, denied.details)verify never returns a bare boolean. A denial includes a reason code, a message, and the comparison that failed (requested vs allowed).
Try it without writing that yourself:
python examples/01_infrastructure.py
python demo/agent.pyThere is no Pigeon server to connect to. You change two places you already have:
- Spawn. Where you would have copied an API key into a sub-agent, call
delegate(...)and give the child a Pass. - Tool. Where the side effect happens (deploy, query, MCP tool), call
verify(...)and do not run the tool if it is denied.
Keep the real secret on the runner. The child carries the Pass.
from pigeon import delegate, grant, verify, DelegationError
parent = grant(
subject="agent:orchestrator",
capabilities=["deploy", "open_pr"],
resources=["environment:staging", "repo:acme/api"],
constraints={"max_deploys_per_hour": 3},
)
worker = delegate(
parent,
subject="agent:pr-bot",
capabilities=["open_pr"],
resources=["repo:acme/api"],
constraints={"max_deploys_per_hour": 3}, # cannot drop a parent constraint
)
result = verify(worker, action="open_pr", resource="repo:acme/api")
assert result.allowed
denied = verify(worker, action="deploy", resource="environment:staging")
assert denied.reason_code == "CAPABILITY_NOT_GRANTED"
try:
delegate(worker, "agent:rogue", ["open_pr", "deploy"], ["repo:acme/api"])
except DelegationError as exc:
assert exc.reason_code == "PRIVILEGE_ESCALATION"A child cannot add capabilities, widen resources, raise a bound, or drop a parent constraint. If Pigeon cannot prove the child is narrower, it rejects.
If the runner never calls verify, the Pass is decoration.
This is an enforcement point, not part of the protocol. The client mints a narrower Pass per tool call. The server verifies it before the tool runs.
from pigeon import grant
from pigeon.integrations.mcp import execute_tool, pass_for_tool
parent = grant(
subject="agent:github",
capabilities=["create_issue", "merge_pr"],
resources=["mcp:github"],
)
tool_pass = pass_for_tool(parent, "create_issue", "mcp:github")
def create_issue(*, title, body):
return {"created": True, "title": title}
ok = execute_tool(tool_pass, "create_issue", "mcp:github",
{"title": "bump deps", "body": "automated"}, create_issue)
assert ok["allowed"]
no = execute_tool(tool_pass, "merge_pr", "mcp:github",
{"title": "nope", "body": "nope"}, create_issue)
assert no["reason_code"] == "CAPABILITY_NOT_GRANTED"Identity tells you who the agent is. Authority tells you what it may do.
pigeon keygen
pigeon inspect pass.jsonPigeon is a small primitive. It is not a platform, a policy engine, an identity provider, or a key custodian. It does not stop prompt injection. It bounds blast radius along the dimensions you put on the Pass, and only those.
- Protocol:
SPEC.md - Limits:
SECURITY.md - More scripts:
examples/(infrastructure, data, code, then payments)