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
38 changes: 34 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,32 @@ All notable changes to this project are documented here. The format follows
Public API names are frozen in `docs/SPEC-v0.1.md` §8. Before 1.0 they may still change, and
any change to one appears here.

## [Unreleased]
## [0.8.0] - 2026-09-12 - Oversight

Every guarantee shipped before this one verifies the principal that **acts**. G7 refuses an action
whose requester cannot be resolved; nothing whatever was asked of the principal that **permits** it.
`approver` was a non-empty string, `ctrlrun delegate --as` was an assertion typed at a shell, and
the operator MCP server authenticated who answered without checking they were entitled to. v0.8
asks the question all seven put only to the acting side: **who may say yes, and can the kernel
tell?**

Five guarantees answer it — G17 an unentitled approver, G18 the requester cannot approve, G19 one
principal counts once, G20 a credential revoked before its `exp`, G21 an unapproved policy decides
nothing — and one thing that is not a guarantee: break-glass, which is a grant and not a flag.

**Opt in, then fail closed.** A deployment that names no approver identity behaves exactly as
0.7.0 did, and a test drives the whole approve-and-execute path to prove it. One that names one has
no partial mode, no "resolve if you can", and no setting that puts the string back. There is no
`skip_entitlement`, no `trust_approver`, no `allow_self_approval`, no `break_glass=True`, no
`ignore_revocations` — and that sentence is a test, not a claim: the shipped package is grepped for
sixteen spellings a flag would take, and the control plants one and finds it.

**What v0.8 does not close, in one place.** A persuaded approver gives a valid approval and the
receipt records it as one. An entitlement check is against what the granting surface **recorded**,
not a re-derivation from a credential that no longer exists. A revoked credential leaves a log line
and no receipt. A feed is worth what its source is worth. And a policy change that no verified
principal other than the proposer approved decides nothing — which is not the same as saying a
policy cannot be changed by whoever holds the file.

### Added

Expand Down Expand Up @@ -118,9 +143,14 @@ any change to one appears here.
controls: [incident-response] # whose approver_role gates who may OPEN it
```

```
ctrlrun break-glass --envelope incident-payments --file grant.yaml --reason "INC-4412"
```
**There is no CLI command for it in 0.8.0.** One was built and withdrawn before the release:
the CLI builds a `Control` that wires no approver identity, and there is no configuration key
for one, so `ctrlrun break-glass` could not succeed in any configuration the CLI can load. It
failed closed, which is the right direction and not a reason to ship it — a command that cannot
work is a claim the CLI makes that the code does not honour. Opening an envelope in 0.8.0 is
reached from an application that built its own `Control`; the shell surface returns in the
milestone that gives the CLI a way to verify an approver. `docs/SPEC-v0.8.md` §14.5 records the
two alternatives and why each was worse.

**The envelope decides nothing, by construction.** It lives in `Authority.envelopes`, a mapping
separate from `grants`, because the candidate set is every entry of `grants` unconditionally: an
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ authors:
- family-names: Ghoshal
given-names: Arpan
email: contact@arpanghoshal.com
version: 0.7.0
version: 0.8.0
repository-code: https://github.com/CTRLRun/ctrlrun
url: https://github.com/CTRLRun/ctrlrun
license: Apache-2.0
Expand Down
2 changes: 1 addition & 1 deletion adapters/langgraph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Route a CTRLRun `APPROVE` through **LangGraph's own `interrupt()`**, so the human answers where
your LangGraph users already answer.

- **Supported kernel range:** `ctrlrun>=0.5,<0.8`
- **Supported kernel range:** `ctrlrun>=0.5,<0.9`
- **Supported framework range:** `langgraph>=1.0,<2.0`
- **Primitive reused:** [`interrupt()` and `Command(resume=...)`](https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/add-human-in-the-loop/), with a checkpointer. Read 2026-09-05.
- **Framework shape:** resumed in place (SPEC-v0.5 §3.5).
Expand Down
2 changes: 1 addition & 1 deletion adapters/langgraph/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ classifiers = [
# surface that has not been written. The README states the same two, and T137 asserts that what
# it states is what CI installed.
dependencies = [
"ctrlrun>=0.5,<0.8",
"ctrlrun>=0.5,<0.9",
"langgraph>=1.0,<2.0",
]

Expand Down
2 changes: 1 addition & 1 deletion adapters/langgraph/src/ctrlrun_langgraph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
buys one thing over it: the interrupt. If your graph has nowhere for a human to answer, or you
are happy for `ApprovalRequired` to reach your own code, use `@protect` and stop here.

Supported kernel range: `ctrlrun>=0.5,<0.8`. Supported framework range: `langgraph>=1.0,<2.0`.
Supported kernel range: `ctrlrun>=0.5,<0.9`. Supported framework range: `langgraph>=1.0,<2.0`.
`README.md` states both, and what this adapter's binding check is and is not.
"""

Expand Down
2 changes: 1 addition & 1 deletion adapters/openai-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Route a CTRLRun `APPROVE` through the **OpenAI Agents SDK's own tool-approval interruption**, so
the human answers where this SDK's users already answer.

- **Supported kernel range:** `ctrlrun>=0.5,<0.8`
- **Supported kernel range:** `ctrlrun>=0.5,<0.9`
- **Supported framework range:** `openai-agents>=0.20,<1.0`
- **Primitive reused:** [`needs_approval`, `RunResult.interruptions`, `RunState.approve` / `reject`](https://openai.github.io/openai-agents-python/tools/). Read 2026-09-05.
- **Framework shape:** decided before invocation (SPEC-v0.5 §3.5).
Expand Down
2 changes: 1 addition & 1 deletion adapters/openai-agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ classifiers = [
# not before, so `>=0.5` would claim compatibility with a surface not yet written. T137 asserts
# the README states these and that CI ran inside them.
dependencies = [
"ctrlrun>=0.5,<0.8",
"ctrlrun>=0.5,<0.9",
"openai-agents>=0.20,<1.0",
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
**You probably do not need this.** `@protect` covers anything in this process with no adapter
and no framework support. This buys the interrupt and nothing else.

Supported kernel range: `ctrlrun>=0.5,<0.8`.
Supported kernel range: `ctrlrun>=0.5,<0.9`.
Supported framework range: `openai-agents>=0.20,<1.0`.
`README.md` states both, and states why this adapter's binding is **attribution** where
LangGraph's is prevention.
Expand Down
43 changes: 31 additions & 12 deletions docs/SPEC-v0.8.md
Original file line number Diff line number Diff line change
Expand Up @@ -982,12 +982,23 @@ line is a silent widening.

### 5.3 Opening it

```python
control._break_glass(
"incident-payments", grant_from_yaml(text), reason="INC-4412"
)
```
ctrlrun break-glass --envelope incident-payments --file grant.yaml --reason "INC-4412"
```

The grant in `--file` is an ordinary one-grant document, as `ctrlrun delegate --file` takes. What
happens is `Control._delegate` with the envelope as parent, so:
**There is no CLI command in 0.8.0, and §14.5 records why.** One was built and removed before the
release: `Control.from_file`, which is what the CLI builds, wires no `ApproverIdentity` and there
is no configuration key for one, so `ctrlrun break-glass` could not succeed in any configuration
the CLI can load. It failed closed, which is the right direction and not a reason to ship it: a
command that cannot work is a claim the CLI makes that the code does not honour, and this
specification refuses that shape everywhere else. The mechanism below is unchanged, is reached
from an embedding application that built its own `Control`, and the shell surface returns in the
milestone that gives the CLI a way to verify an approver.

The grant is an ordinary one-grant document, as `ctrlrun delegate --file` takes. What happens is
`Control._delegate` with the envelope as parent, so:

- **containment is checked by the code that already checks it**, `contained_dimension`, on every
dimension it knows, which is six and includes `expires_at`. A grant wider than the envelope on
Expand Down Expand Up @@ -1858,7 +1869,7 @@ the section it cites, and only then is there code.
| One policy block, and what loading it changes | 5 | `authority.break_glass`, entries with `max_ttl`; `Authority.envelopes`; `canonical_grants` renders envelopes | The envelope must be covered by the policy hash and must never decide an action, and `_candidates` returns every entry of `_grants` unconditionally. A separate mapping is the only shape that gives both without a filter somebody can delete (§5.2). |
| Two lookups in the walk | 5 | `Authority._walk` and `_parent_for_creation` resolve a root from `_grants`, then `envelopes` | A break-glass delegation names an envelope as its parent, and the walk resolves roots from `_grants` alone today (§5.2). |
| One `created_via` value | 5 | the third value beside `api` and `cli` | The vocabulary is a closed `Literal` and an unknown value makes a row unreadable, which makes `Authority.evaluate` answer `authority_unreadable` for every action in the deployment. The `Literal`, the mapping and every reader move together (§5.3). |
| One CLI command | 5 | `ctrlrun break-glass --envelope --file --reason` | Opening one is an act and acts get commands; it reuses `delegate`'s parsing. No `--as`: the opener is the resolved principal, never an assertion (§5.3.1). |
| ~~One CLI command~~ | 5 | **Withdrawn before 0.8.0.** `Control._break_glass`, package-internal beside `_delegate` | The command was built and removed: the CLI builds `Control.from_file`, which wires no `ApproverIdentity`, so it could not succeed in any configuration the CLI can load (§5.3, §14.5). No `--as` was the rule while it existed, and it is why the replacement takes no principal either: the opener is the resolved principal, never an assertion (§5.3.1). |
| Two receipt fields | 2, 5 | `Receipt.approvers`, `Receipt.authority_grant_id` | What §2 verified has to reach the evidence. `AuthorityResult.grant_id` reaches the events already and nothing puts it on the receipt (§5.4). |
| One module | 6 | `ctrlrun.revocation`: `RevocationFeed` (with `max_staleness`), `FileRevocationFeed`, `PollingRevocationFeed`; `JWTIdentityProvider(revocations=...)` | Consuming a SET needs JWT verification, which is why it is in `ctrlrun[identity]` beside the provider it serves and not in core. |
| Two operator-server options | 2, 3 | `ctrlrun mcp-operator --approver-roles-claim`, and the server's existing provider used as the approver identity | Without a configuration surface the only verifying surface in §2.6 could not be configured (§2.6). |
Expand Down Expand Up @@ -2290,19 +2301,27 @@ does not bound them, so an envelope intended to bound resources must say so. Omi
unlimited *for the child* — a child may not drop a dimension its parent constrains — and it is
also not a constraint the parent never expressed.

**Open, and the maintainer's call: `ctrlrun break-glass` cannot succeed in any configuration
the CLI can load.** `Control.from_file` wires no `ApproverIdentity` -- there is no configuration
**Decided, and the command was withdrawn: `ctrlrun break-glass` could not succeed in any
configuration the CLI can load.** `Control.from_file` wires no `ApproverIdentity` -- there is no configuration
key for one, and §2.6.1 rules out giving the CLI a provider of its own -- so the command §11.1 adds
for this item always exits 1 saying an approver identity is needed, which is advice the CLI cannot
act on. It fails closed, and the gated path is the only path: `delegate --parent <an envelope>` is
refused by name. But it means the shell example in §5.3 does not run today, and break-glass is
reachable only from an embedding application that built its own `Control`.

Three ways out, none of them a build item's to choose: a configuration key naming an approver
identity provider, which is surface §11.1 does not list; a credential option on this one command,
which is the same surface with a narrower blast radius; or striking the shell example and saying
the command serves deployments that configure one in code. **Asked rather than decided**, on the
rule that an item which disagrees with its specification stops.
Three ways out were on the table and the third was taken. A **configuration key** naming the
approver's identity provider is the worst of them and not merely the largest: it would let whoever
holds the policy file decide who verifies approvers, which is the direction §8.4 refuses for
`require_approved_policy` and for the same reason. A **credential option** on the one command
needs the whole JWT configuration the CLI does not have -- issuer, audience, key source, algorithm
list -- so it is not one flag but ten, invented under release pressure and reviewed by nobody.

So the command is **withdrawn**, the mechanism ships, and the shell surface returns in the
milestone that gives the CLI a way to verify an approver. What that costs is stated rather than
hidden: **break-glass in 0.8.0 is reachable only from an application that builds its own
`Control`**, and an operator whose incident response is a shell has nothing here yet. What it
avoids is a command on `ctrlrun --help` that always exits 1, which is the shape this project
refuses when it is a guard and should refuse when it is a door.

**The absence test had to read code rather than text.** `approval.py` explains in a comment that a
public `_granting_principal` would be "`trust_approver` spelled as a context manager", which is
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "ctrlrun"
version = "0.7.0"
version = "0.8.0"
description = "The execution safety layer for AI agents."
# Mirrors the repository's GitHub topics, so PyPI search and GitHub search agree.
keywords = [
Expand Down
46 changes: 0 additions & 46 deletions src/ctrlrun/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1071,52 +1071,6 @@ def policy_replay(candidate_file: Path, limit: int, as_json: bool, store_url: st
)


@main.command("break-glass")
@click.option(
"--envelope",
required=True,
help="The break-glass envelope declared under 'authority: break_glass:'.",
)
@click.option(
"--file",
"grant_file",
required=True,
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="A one-grant YAML document, as --file takes for delegate. 'expires_at' is required.",
)
@click.option("--reason", default="", help="Free text recorded on the DELEGATION_CREATED event.")
@click.option("--json", "as_json", is_flag=True, help="Emit one JSON object instead.")
@STORE_URL_OPTION
def break_glass(
envelope: str, grant_file: Path, reason: str, as_json: bool, store_url: str | None
) -> None:
"""Open a break-glass grant beneath a declared envelope.

An incident needs authority nobody was granted in advance, and the wrong answer is a flag:
a flag leaves no record, expires never and cannot be revoked. What this creates is an
ordinary delegation, bounded by the envelope on every dimension, expiring within its
'max_ttl', revocable, attenuable, and named on the receipt of every action taken under it.

**There is no --as.** Whoever opens one is the principal the deployment's approver identity
resolves, and a deployment that names none cannot open one at all: an assertion typed at a
shell is exactly what break-glass must not accept (SPEC-v0.8 §5.3.1).
"""
try:
control = _control_on(store_url)
grant = grant_from_yaml(grant_file.read_text(encoding="utf-8"), source=str(grant_file))
created = control._break_glass(envelope, grant, reason=reason)
except AuthorityEscalation as exc:
raise click.ClickException(f"{exc.reason}: {exc}") from exc
except CTRLRunError as exc:
raise _fail(exc) from exc
if as_json:
click.echo(json.dumps(_delegation_dict(created), ensure_ascii=False))
return
click.echo(f"opened {created.delegation_id} beneath {created.parent_id}")
click.echo(f"expires {created.grant.expires_at}")
click.echo(f"revoke it with: ctrlrun revoke {created.delegation_id}")


@main.command()
@click.argument("delegation_id", required=False)
@click.option("--by", "by", default=CLI_APPROVER, show_default=True, help="Who revoked it.")
Expand Down
3 changes: 0 additions & 3 deletions tests/test_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -1095,9 +1095,6 @@ def test_the_cli_offers_exactly_the_commands_the_spec_freezes():
# SPEC-v0.3 §5.7 — build-list item 3.
"delegate",
"revoke",
# SPEC-v0.8 §5.3 — break-glass is a grant, so opening one is a delegation with its own
# command rather than a flag on this list.
"break-glass",
# SPEC-v0.8 §8.3 — a policy change is an action, so proposing one is a command. No
# `policy approve`: that is `ctrlrun approve`.
"policy",
Expand Down
3 changes: 0 additions & 3 deletions tests/test_policy_versioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -1591,9 +1591,6 @@ def test_T177c_the_command_list_is_exactly_the_one_the_spec_froze():
# a v0.6 feature than that one: §9.4's claim is about the surface *this milestone* grew,
# and a subcommand landing before the tag does not retroactively make it one.
"scan",
# SPEC-v0.8 §5.3, §11.1. Opening a break-glass envelope is an act, and acts get
# commands. It is a v0.8 addition on the v0.8 line, not a v0.6 one.
"break-glass",
# SPEC-v0.8 §8.3, §11.1. A group, not a command: `propose` and `replay` live under it.
# There is deliberately no `policy approve` -- a proposal is an ordinary approval
# request, so `ctrlrun approve` answers it, and a second command would be a second
Expand Down
Loading