Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,37 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.3.3] — 2026-06-11

Implements the **§9 authorization token** (optional profile). Protocol unchanged
(still 0.3) — the token is additive and changes no hashed or signed bytes; it's
off by default, so existing deployments are byte-for-byte unchanged.

### Added
- **`delego.token`** — a portable, signed PDP→PEP decision artifact (spec §9):
a compact JWS / JWT with `alg = EdDSA`. Built on `cryptography` (already a
dependency) — **no new dependency**, no PyJWT.
- **`TokenIssuer`** mints a short-lived token (TTL ≤ 300s, default 45s) for an
`allow` outcome or a released approval — and *only* those; `deny`,
`needs_approval`, `denied`, and `consumed` mint nothing.
- **`verify_token`** performs §9.1 checks 1–4: pins `EdDSA` and rejects
`none`/algorithm-confusion (the key comes from verifier config, never the
token header), exact `aud` match, expiry with bounded clock skew, and
single-use `jti` (replay) and `cns` (consumption nonce).
- **`require_fingerprint`** is §9.1 step 5 — the crux: a broker recomputes the
fingerprint of the request it's about to send and requires it equals the
token's `fpr`. This is the enforcement-side closure of the "declared vs.
sent" gap (a token minted for action A cannot release action B).
- **`Firewall(token_issuer=..., token_audience=...)`** and
**`build_firewall(..., mint_tokens=True)`** wire the profile; the minted token
rides on `Decision.token`. A **separate** token signing key
(`token_key.pem`, distinct from the audit key per spec §9) is generated on
first use.
- Brokers accept an optional `token=` keyword (non-breaking — a bare
`execute(action)` adapter still works); `HTTPProxyBroker` forwards the token to
the gateway as `authorization_token` so the separated PEP can verify it.
- Exports: `TokenIssuer`, `verify_token`, `require_fingerprint`, `TokenError`.

## [0.3.2] — 2026-06-10

Packaging only; protocol unchanged (still 0.3). No functional or API changes.
Expand Down Expand Up @@ -276,7 +307,8 @@ published.) Implements wire-protocol **0.2**; see
a FastMCP server exposing propose / resolve / audit_tail / show_policy.
- `NullBroker` (default; holds no credentials) and an `HTTPProxyBroker` sketch.

[Unreleased]: https://github.com/Delego-Dev/delego/compare/v0.3.2...HEAD
[Unreleased]: https://github.com/Delego-Dev/delego/compare/v0.3.3...HEAD
[0.3.3]: https://github.com/Delego-Dev/delego/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/Delego-Dev/delego/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/Delego-Dev/delego/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/Delego-Dev/delego/compare/v0.2.4...v0.3.0
Expand Down
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,16 +206,19 @@ See **[ROADMAP.md](ROADMAP.md)** for where delego is going and where to help.
- **Implemented (protocol 0.3):** the policy engine, intent hashing, action
fingerprinting **with the URL query folded into the fingerprint** (spec §4.2 —
`/orders?to=me` and `/orders?to=attacker` are different actions), the
confused-deputy guard, intent-bound + single-use human approvals, and the
confused-deputy guard, intent-bound + single-use human approvals, the
signed, hash-chained audit ledger with verification and an external
head-anchor check (`delego verify --expected-head`).
head-anchor check (`delego verify --expected-head`), and the **§9 authorization
token** (optional profile) — a short-lived, EdDSA-signed JWS a separated broker
verifies before injecting a credential (`build_firewall(..., mint_tokens=True)`;
`verify_token` / `require_fingerprint`).
- **Brokers:** the default `NullBroker` holds no credentials and makes no real
request — it records what *would* be sent (for demos and tests). `HTTPProxyBroker`
forwards an authorised action to an external credential gateway; or write your
own against the `BrokerAdapter` protocol in `delego/brokers.py`.
- **Not yet:** the authorization token (spec §9, an optional profile), an
always-on daemon (state is file-backed and shared by the CLI and MCP server),
and a non-MCP HTTP surface.
forwards an authorised action — and its authorization token — to an external
credential gateway; or write your own against the `BrokerAdapter` protocol in
`delego/brokers.py`.
- **Not yet:** an always-on daemon (state is file-backed and shared by the CLI
and MCP server), and a non-MCP HTTP surface.
- **Known limitations:** concurrent writes to the file-backed ledger and approval
store are serialised with an OS file lock (corruption-safe). Rate limits are
**exact on a single host**: a policy carrying a `rate_limit` runs each propose
Expand Down
12 changes: 6 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ implements and `y` the iteration. Normative changes land in the spec first.
the `action_fingerprint`, so decision-relevant data can't ride it (0.3.0,
breaking; regenerated CTK vectors). Rate-limited proposes are serialized under
the ledger lock, making the cap exact on a single host (0.3.0).
- **Signed authorization token** (spec §9) — on `allow`/release the authorizer
mints a short-lived EdDSA JWS bound to the action fingerprint + intent; a
separated broker verifies it (pin EdDSA, exact `aud`, single-use `jti`/`cns`)
and re-checks the fingerprint of the request it's about to send before
injecting a credential (0.3.3). No new dependency.

## Now — make it usable in production (protocol 0.3)

Expand All @@ -46,12 +51,7 @@ implements and `y` the iteration. Normative changes land in the spec first.

## Next — differentiate and harden (protocol 0.3, spec-first)

4. **Signed authorization token** (spec §9, already drafted). On allow/approve,
mint a short-lived JWS bound to the action fingerprint + intent; a broker
verifies it before injecting a credential. Closes the gap where a broker would
inject for *any* in-scope request, and makes delego composable across vendors.
*This is the moat.*
5. **Single-writer daemon.** A long-running process so non-MCP clients work, the
4. **Single-writer daemon.** A long-running process so non-MCP clients work, the
CLI + MCP share live state over a socket, and rate-limit counting is exact
across hosts without holding a file lock through broker calls (0.2.1 made
writes corruption-safe; 0.3.0 made the cap exact on one host; this makes it
Expand Down
7 changes: 6 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ Be precise about what delego does and does not protect against:
The agent supplies `params`, `url`, and `instruction`; delego trusts them. Its
guarantees hold only if your broker executes exactly the fingerprinted action.
delego is a control only when the credential is reachable **solely** through the
broker — an agent with raw network/exec can bypass it entirely.
broker — an agent with raw network/exec can bypass it entirely. The
enforcement-side closure is the §9 token's step-5 re-check
(`require_fingerprint`): a token-requiring broker recomputes the fingerprint of
the request it is about to send and refuses if it doesn't equal the authorized
`fpr`, so an agent that declared one action but tries to send another is
refused at the PEP.
- **The recorded intent is the agent's claim.** `intent_hash` binds the
instruction the agent *reported*; it is not verified against the real human
request, so under a fully compromised agent the audited intent is
Expand Down
7 changes: 6 additions & 1 deletion delego/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@
ProposedAction,
)
from .policy import Policy, PolicyError
from .token import TokenError, TokenIssuer, require_fingerprint, verify_token

__version__ = "0.3.2" # PyPI package version, 0.x.y (x = protocol, y = iteration)
__version__ = "0.3.3" # PyPI package version, 0.x.y (x = protocol, y = iteration)

# Highest delego *protocol* version (see the wire spec's "Protocol versions")
# this reference implements. Protocol/spec versions are 0.x (two-component); the
Expand All @@ -44,6 +45,10 @@
"ensure_keys",
"Paths",
"build_firewall",
"TokenIssuer",
"verify_token",
"require_fingerprint",
"TokenError",
"OUTCOME_ALLOW",
"OUTCOME_DENY",
"OUTCOME_APPROVAL",
Expand Down
45 changes: 30 additions & 15 deletions delego/brokers.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,19 @@ class BrokerAdapter(Protocol):
and MUST refuse — raise :class:`BrokerRefusal`, never silently strip — when
``action.url`` carries a fragment (:attr:`ProposedAction.has_fragment`)
that the fingerprint does not represent.

**Authorization token (spec §9, optional).** When a token issuer is
configured the firewall passes the minted token as the ``token`` keyword. A
*separated* broker (one across a process/network boundary from the
authorizer) SHOULD verify it (:func:`delego.verify_token` +
:func:`delego.require_fingerprint`) before injecting a credential. A broker
that does not implement the token may keep a bare ``execute(action)``
signature — the firewall falls back to it.
"""

name: str

def execute(self, action: ProposedAction) -> dict[str, Any]:
def execute(self, action: ProposedAction, token: str | None = None) -> dict[str, Any]:
...


Expand Down Expand Up @@ -86,14 +94,17 @@ class NullBroker:
def __init__(self) -> None:
self.sent: list[dict] = []

def execute(self, action: ProposedAction) -> dict[str, Any]:
def execute(self, action: ProposedAction, token: str | None = None) -> dict[str, Any]:
# Honour the execution contract even when simulating: a stray fragment
# is unauthorised data regardless of whether a real request is made.
_require_no_unauthorised_fragment(action)
record = {
"broker": self.name,
"would_send": action.summary(),
"note": "stub: no credential injected, no upstream request made",
# Surfaced so the decision -> execution loop (including a minted §9
# token) is observable end to end; NullBroker verifies nothing.
"token": token,
}
self.sent.append(record)
return {"status": "simulated", "detail": record}
Expand Down Expand Up @@ -138,7 +149,7 @@ def __init__(
self.timeout = timeout
self._headers = {"content-type": "application/json", **(gateway_headers or {})}

def execute(self, action: ProposedAction) -> dict[str, Any]:
def execute(self, action: ProposedAction, token: str | None = None) -> dict[str, Any]:
import json
import urllib.error
import urllib.request
Expand All @@ -147,18 +158,22 @@ def execute(self, action: ProposedAction) -> dict[str, Any]:
# data outside the fingerprint preimage (spec §4.2).
_require_no_unauthorised_fragment(action)

payload = json.dumps(
{
"method": action.method.upper(),
# Forward only the fingerprinted URL (scheme+host+path+query);
# the fragment is never represented in the fingerprint, so it is
# never sent.
"url": action.fingerprinted_url,
"params": action.params,
"intent_hash": action.intent_hash,
"action_fingerprint": action.fingerprint,
}
).encode("utf-8")
body: dict[str, Any] = {
"method": action.method.upper(),
# Forward only the fingerprinted URL (scheme+host+path+query); the
# fragment is never represented in the fingerprint, so it is never
# sent.
"url": action.fingerprinted_url,
"params": action.params,
"intent_hash": action.intent_hash,
"action_fingerprint": action.fingerprint,
}
# When a §9 token was minted, forward it so the gateway can verify the
# authorization (and re-check the fingerprint of what it's about to send)
# before injecting the credential. The gateway is the separated PEP.
if token is not None:
body["authorization_token"] = token
payload = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
self.gateway_url, data=payload, method="POST", headers=self._headers
)
Expand Down
41 changes: 39 additions & 2 deletions delego/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
_STATE_GITIGNORE = """\
# delego runtime state — do not commit secrets or the audit ledger.
signing_key.pem
token_key.pem
audit.log.jsonl
approvals.jsonl
*.lock
Expand Down Expand Up @@ -84,6 +85,16 @@ def approvals(self) -> Path:
def policy(self) -> Path:
return self.home / "policy.yaml"

@property
def token_private_key(self) -> Path:
# Distinct from the audit signing key (spec §9): a token-minting
# compromise must not be able to forge the audit chain.
return self.home / "token_key.pem"

@property
def token_public_key(self) -> Path:
return self.home / "token_key.pub"


def ensure_home_gitignore(home: str | os.PathLike) -> None:
"""Drop a ``.gitignore`` in the home so keys/ledger aren't committed when the
Expand All @@ -95,12 +106,38 @@ def ensure_home_gitignore(home: str | os.PathLike) -> None:
gitignore.write_text(_STATE_GITIGNORE, encoding="utf-8")


def build_firewall(paths: Paths, broker: BrokerAdapter | None = None) -> Firewall:
def build_firewall(
paths: Paths,
broker: BrokerAdapter | None = None,
*,
mint_tokens: bool = False,
token_audience: str = "broker:default",
) -> Firewall:
# Load policy first: a missing/invalid policy fails closed with a clear
# message before any state (keys, ledger) is created.
policy = Policy.load(paths.policy)
ensure_home_gitignore(paths.home)
ensure_keys(paths.private_key, paths.public_key)
audit = AuditLog(paths.audit_log, paths.private_key, paths.public_key)
approvals = ApprovalStore(paths.approvals)
return Firewall(policy, audit, approvals, broker=broker)

token_issuer = None
if mint_tokens:
# The §9 profile is opt-in: only when requested do we generate the
# (separate) token key and wire an issuer. Off by default, so existing
# deployments are byte-for-byte unchanged.
from .token import TokenIssuer

token_issuer = TokenIssuer.from_files(
paths.token_private_key,
paths.token_public_key,
issuer="delego:local",
)
return Firewall(
policy,
audit,
approvals,
broker=broker,
token_issuer=token_issuer,
token_audience=token_audience,
)
53 changes: 52 additions & 1 deletion delego/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

from __future__ import annotations

from typing import TYPE_CHECKING

from .approval import (
STATUS_APPROVED,
STATUS_CONSUMED,
Expand All @@ -33,6 +35,9 @@
)
from .policy import Policy

if TYPE_CHECKING:
from .token import TokenIssuer


class Firewall:
def __init__(
Expand All @@ -41,11 +46,20 @@ def __init__(
audit: AuditLog,
approvals: ApprovalStore,
broker: BrokerAdapter | None = None,
token_issuer: "TokenIssuer | None" = None,
token_audience: str = "broker:default",
) -> None:
self.policy = policy
self.audit = audit
self.approvals = approvals
self.broker = broker or NullBroker()
# Optional §9 profile: when wired, the firewall mints a short-lived
# authorization token for `allow` outcomes (and released approvals) and
# attaches it to the Decision for a separated broker to verify. Default
# off — the in-process broker already trusts the decision, so behaviour
# and receipts are unchanged when this is None.
self.token_issuer = token_issuer
self.token_audience = token_audience

# ------------------------------------------------------------------ #
def propose(self, action: ProposedAction) -> Decision:
Expand Down Expand Up @@ -202,9 +216,14 @@ def _execute(
crash between authorisation and execution leaves no trace the action
was ever authorised. The failure is recorded as an ``execution``/deny
receipt and the exception re-raised for the caller.

If a token issuer is configured, a §9 authorization token is minted for
this `allow` and handed to the broker (which MAY verify it before
injecting) and returned on the Decision.
"""
token = self._mint_token(rule, intent_hash, fingerprint, approval_id)
try:
result = self.broker.execute(action)
result = self._broker_execute(action, token)
except Exception as e:
audit.append(
phase="execution",
Expand Down Expand Up @@ -236,4 +255,36 @@ def _execute(
approval_id=approval_id,
executed=True,
result=result,
token=token,
)

# ------------------------------------------------------------------ #
def _mint_token(self, rule, intent_hash, fingerprint, approval_id):
"""Mint a §9 token for an `allow`, or None if no issuer is configured.

Only ever called from :meth:`_execute`, i.e. for `allow` and released
approvals — never for `deny`/`needs_approval`/`denied`/`consumed`, which
per spec §9 MUST NOT mint."""
if self.token_issuer is None:
return None
return self.token_issuer.mint(
action_fingerprint=fingerprint,
intent_hash=intent_hash,
audience=self.token_audience,
approval_id=approval_id,
policy_version=self.policy.version,
rule=rule,
)

def _broker_execute(self, action, token):
"""Call the broker, passing the token when the broker accepts one.

Brokers MAY take an optional ``token`` keyword (the shipped adapters do);
a 0.2/0.3-era adapter with a bare ``execute(action)`` keeps working."""
if token is None:
return self.broker.execute(action)
try:
return self.broker.execute(action, token=token)
except TypeError:
# Broker predates the token kwarg — fall back, unchanged behaviour.
return self.broker.execute(action)
4 changes: 4 additions & 0 deletions delego/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ class Decision:
approval_id: Optional[str] = None
executed: bool = False
result: Optional[dict] = None
# The §9 authorization token, when a token issuer is configured and the
# outcome is `allow` (or a released approval). A short-lived JWS a separated
# broker can verify before injecting a credential. None otherwise.
token: Optional[str] = None

@property
def allowed(self) -> bool:
Expand Down
Loading