revoco 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- revoco/__init__.py +183 -0
- revoco/adapters/__init__.py +163 -0
- revoco/adapters/cloud.py +362 -0
- revoco/adapters/database.py +217 -0
- revoco/adapters/devops.py +362 -0
- revoco/adapters/identity.py +256 -0
- revoco/adapters/saas.py +237 -0
- revoco/adapters/sap.py +358 -0
- revoco/adapters/workday.py +291 -0
- revoco/adapters/workstation.py +305 -0
- revoco/authority/__init__.py +31 -0
- revoco/authority/action.py +151 -0
- revoco/authority/delegation.py +133 -0
- revoco/authority/engine.py +403 -0
- revoco/authority/principals.py +167 -0
- revoco/authority/revocation.py +101 -0
- revoco/authority/scope.py +227 -0
- revoco/bench/__init__.py +77 -0
- revoco/bench/corpus.py +1168 -0
- revoco/bench/harness.py +352 -0
- revoco/bench/report.py +343 -0
- revoco/bench/scenario.py +295 -0
- revoco/bench/world.py +351 -0
- revoco/cli.py +299 -0
- revoco/controlplane.py +778 -0
- revoco/core/__init__.py +42 -0
- revoco/core/crypto.py +146 -0
- revoco/core/errors.py +90 -0
- revoco/core/ids.py +31 -0
- revoco/demo.py +376 -0
- revoco/detect.py +552 -0
- revoco/drills.py +586 -0
- revoco/evidence.py +362 -0
- revoco/gate/__init__.py +36 -0
- revoco/gate/conditions.py +192 -0
- revoco/gate/decision.py +67 -0
- revoco/gate/engine.py +222 -0
- revoco/gate/policy.py +326 -0
- revoco/gate/session.py +71 -0
- revoco/gate/threats.py +175 -0
- revoco/ledger.py +275 -0
- revoco/reversal/__init__.py +62 -0
- revoco/reversal/budget.py +310 -0
- revoco/reversal/engine.py +768 -0
- revoco/reversal/model.py +938 -0
- revoco/reversal/registry.py +224 -0
- revoco-0.1.1.dist-info/METADATA +302 -0
- revoco-0.1.1.dist-info/RECORD +51 -0
- revoco-0.1.1.dist-info/WHEEL +4 -0
- revoco-0.1.1.dist-info/entry_points.txt +2 -0
- revoco-0.1.1.dist-info/licenses/LICENSE +201 -0
revoco/__init__.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Revoco — an action control plane for AI agents.
|
|
3
|
+
|
|
4
|
+
Four questions, one pipeline, one verifiable record:
|
|
5
|
+
|
|
6
|
+
1. **Authority** — is there valid authority for this, traceable to a human?
|
|
7
|
+
2. **Enforcement** — does policy permit this specific action, with these
|
|
8
|
+
arguments, under this session's accumulated spend?
|
|
9
|
+
3. **Reversibility** — if this turns out to be wrong, can we take it back?
|
|
10
|
+
4. **Evidence** — can we prove all of the above to someone who does not trust us?
|
|
11
|
+
|
|
12
|
+
Question 3 is the one most agent-governance tooling leaves out, and it is the one
|
|
13
|
+
that decides whether an incident costs an afternoon or a quarter.
|
|
14
|
+
|
|
15
|
+
Quick start::
|
|
16
|
+
|
|
17
|
+
from revoco import ControlPlane, Scope, crypto
|
|
18
|
+
from revoco.reversal import ap_starter_registry
|
|
19
|
+
|
|
20
|
+
cp = ControlPlane(inverse_registry=ap_starter_registry())
|
|
21
|
+
|
|
22
|
+
h_priv, h_pub = crypto.generate_keypair()
|
|
23
|
+
a_priv, a_pub = crypto.generate_keypair()
|
|
24
|
+
cfo = cp.register_human("Alice (CFO)", h_pub)
|
|
25
|
+
bot = cp.register_agent("ap-bot", a_pub, roles={"ap-clerk"})
|
|
26
|
+
|
|
27
|
+
grant = cp.issue_root_delegation(
|
|
28
|
+
human_private_key=h_priv, human_id=cfo.id, agent_id=bot.id,
|
|
29
|
+
scope=Scope.make(tools={"invoices.pay"}, actions={"write"}, max_risk=60),
|
|
30
|
+
purpose="pay approved invoices", ttl_seconds=3600,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
v = cp.authorize(
|
|
34
|
+
actor_private_key=a_priv, actor_id=bot.id, delegation_id=grant.id,
|
|
35
|
+
tool="invoices.pay", args={"invoice_id": "INV-1", "amount": 900},
|
|
36
|
+
risk=50, description="pay approved invoice INV-1",
|
|
37
|
+
)
|
|
38
|
+
if v.allowed:
|
|
39
|
+
result = pay(...) # your code does the real work
|
|
40
|
+
cp.confirm(v, result=result)
|
|
41
|
+
|
|
42
|
+
cp.undo(v.action_id, my_executor) # one action
|
|
43
|
+
cp.contain(grant.id, my_executor) # revoke the grant, roll back its subtree
|
|
44
|
+
|
|
45
|
+
This package merges three earlier tools — ``veritrail`` (provenance and signed
|
|
46
|
+
delegation), ``mcp-gate`` (per-call policy enforcement), and ``mnemosyne``
|
|
47
|
+
(memory/context integrity) — and adds the reversal layer that none of them had.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from .authority import (
|
|
51
|
+
ActionRecord,
|
|
52
|
+
AuthorityEngine,
|
|
53
|
+
ChainResult,
|
|
54
|
+
Delegation,
|
|
55
|
+
Principal,
|
|
56
|
+
PrincipalKind,
|
|
57
|
+
Scope,
|
|
58
|
+
)
|
|
59
|
+
from .controlplane import ControlPlane, Verdict
|
|
60
|
+
from .core import crypto, ids
|
|
61
|
+
from .core.errors import RevocoError
|
|
62
|
+
from .detect import DetectionEngine, Finding, Severity, journal_health
|
|
63
|
+
from .drills import (
|
|
64
|
+
Canary,
|
|
65
|
+
DrillOutcome,
|
|
66
|
+
DrillResult,
|
|
67
|
+
DrillRunner,
|
|
68
|
+
RecoverabilityAttestation,
|
|
69
|
+
RecoverabilityRegister,
|
|
70
|
+
attest,
|
|
71
|
+
)
|
|
72
|
+
from .evidence import EvidencePack, build_evidence_pack, readiness_report
|
|
73
|
+
from .gate import Decision, Effect, Policy, PolicyEngine, load_policy, starter_policy
|
|
74
|
+
from .ledger import Ledger, LedgerEntry
|
|
75
|
+
from .reversal import (
|
|
76
|
+
CascadeReport,
|
|
77
|
+
InverseRegistry,
|
|
78
|
+
InverseSpec,
|
|
79
|
+
InverseStep,
|
|
80
|
+
JournalEntry,
|
|
81
|
+
JournalState,
|
|
82
|
+
ReversalEngine,
|
|
83
|
+
ReversalGate,
|
|
84
|
+
ReversalPlan,
|
|
85
|
+
ReversalReceipt,
|
|
86
|
+
Reversibility,
|
|
87
|
+
ap_starter_registry,
|
|
88
|
+
)
|
|
89
|
+
from .reversal.budget import IrreversibilityBudget
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _detect_version() -> str:
|
|
93
|
+
"""Read the version from installed package metadata.
|
|
94
|
+
|
|
95
|
+
``pyproject.toml`` is the single source of truth, so a released wheel and
|
|
96
|
+
``revoco.__version__`` can never disagree — the release workflow bumps one
|
|
97
|
+
number in one file and this follows automatically.
|
|
98
|
+
|
|
99
|
+
Resolved through ``packages_distributions()`` rather than a hardcoded
|
|
100
|
+
distribution name. Import name and distribution name happen to match today, and
|
|
101
|
+
this keeps working if they ever stop matching — a hardcoded name would silently
|
|
102
|
+
return the fallback instead of raising, which is the worst way to be wrong about
|
|
103
|
+
a version number.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
from importlib.metadata import (
|
|
107
|
+
PackageNotFoundError,
|
|
108
|
+
packages_distributions,
|
|
109
|
+
version,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
for dist in packages_distributions().get("revoco", []):
|
|
113
|
+
try:
|
|
114
|
+
return version(dist)
|
|
115
|
+
except PackageNotFoundError:
|
|
116
|
+
continue
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
# Running from a source checkout with nothing installed.
|
|
120
|
+
return "0.0.0+local"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
__version__ = _detect_version()
|
|
124
|
+
|
|
125
|
+
__all__ = [
|
|
126
|
+
"__version__",
|
|
127
|
+
# control plane
|
|
128
|
+
"ControlPlane",
|
|
129
|
+
# unrecoverable-exposure ceiling
|
|
130
|
+
"IrreversibilityBudget",
|
|
131
|
+
# recovery drills: reversibility as a claim that expires
|
|
132
|
+
"DrillRunner",
|
|
133
|
+
"Canary",
|
|
134
|
+
"DrillOutcome",
|
|
135
|
+
"DrillResult",
|
|
136
|
+
"RecoverabilityRegister",
|
|
137
|
+
"RecoverabilityAttestation",
|
|
138
|
+
"attest",
|
|
139
|
+
"Verdict",
|
|
140
|
+
# authority
|
|
141
|
+
"Scope",
|
|
142
|
+
"Principal",
|
|
143
|
+
"PrincipalKind",
|
|
144
|
+
"Delegation",
|
|
145
|
+
"ActionRecord",
|
|
146
|
+
"AuthorityEngine",
|
|
147
|
+
"ChainResult",
|
|
148
|
+
# enforcement
|
|
149
|
+
"Policy",
|
|
150
|
+
"PolicyEngine",
|
|
151
|
+
"Decision",
|
|
152
|
+
"Effect",
|
|
153
|
+
"load_policy",
|
|
154
|
+
"starter_policy",
|
|
155
|
+
# reversal
|
|
156
|
+
"Reversibility",
|
|
157
|
+
"InverseSpec",
|
|
158
|
+
"InverseStep",
|
|
159
|
+
"ReversalGate",
|
|
160
|
+
"InverseRegistry",
|
|
161
|
+
"ap_starter_registry",
|
|
162
|
+
"ReversalEngine",
|
|
163
|
+
"ReversalPlan",
|
|
164
|
+
"ReversalReceipt",
|
|
165
|
+
"JournalEntry",
|
|
166
|
+
"JournalState",
|
|
167
|
+
"CascadeReport",
|
|
168
|
+
# detection
|
|
169
|
+
"DetectionEngine",
|
|
170
|
+
"Finding",
|
|
171
|
+
"Severity",
|
|
172
|
+
"journal_health",
|
|
173
|
+
# evidence
|
|
174
|
+
"EvidencePack",
|
|
175
|
+
"build_evidence_pack",
|
|
176
|
+
"readiness_report",
|
|
177
|
+
# infrastructure
|
|
178
|
+
"Ledger",
|
|
179
|
+
"LedgerEntry",
|
|
180
|
+
"crypto",
|
|
181
|
+
"ids",
|
|
182
|
+
"RevocoError",
|
|
183
|
+
]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
System-of-record adapters: the inverse-operation specs that do not generalize.
|
|
3
|
+
|
|
4
|
+
Everything else in revoco is transferable across customers. This package is not:
|
|
5
|
+
knowing that an SAP payment reversal is a three-step sequence, that a Workday
|
|
6
|
+
rescind dies the moment payroll runs, or that an S3 delete is recoverable only if
|
|
7
|
+
someone enabled versioning first, is per-system knowledge that has to be built once
|
|
8
|
+
and maintained forever.
|
|
9
|
+
|
|
10
|
+
Seven surfaces are covered:
|
|
11
|
+
|
|
12
|
+
============ =============================================================
|
|
13
|
+
``sap`` S/4HANA financial postings, supplier master, payments
|
|
14
|
+
``workday`` HCM business processes — compensation, staffing, payroll
|
|
15
|
+
``cloud`` AWS — S3, IAM, EC2/networking, RDS, Route 53, KMS
|
|
16
|
+
``identity`` Microsoft Entra ID and Okta
|
|
17
|
+
``devops`` GitHub refs and protection, Kubernetes, feature flags
|
|
18
|
+
``saas`` Salesforce records, Slack messages, Stripe payments
|
|
19
|
+
``workstation`` filesystem, git, shell — the surface coding agents touch
|
|
20
|
+
``database`` row writes, arbitrary SQL, schema migrations
|
|
21
|
+
============ =============================================================
|
|
22
|
+
|
|
23
|
+
Every spec is **unvalidated** — written from documentation and practitioner
|
|
24
|
+
sources, not executed against a live system. See ``docs/ADAPTERS.md`` for
|
|
25
|
+
per-spec citations and the validation checklist to work through before any of it
|
|
26
|
+
governs a real write.
|
|
27
|
+
|
|
28
|
+
The two things this collection taught the core model
|
|
29
|
+
----------------------------------------------------
|
|
30
|
+
**Reversibility is a property of the target, not the tool.** ``aws.s3.delete_object``
|
|
31
|
+
is recoverable against a versioned bucket and final against an unversioned one, with
|
|
32
|
+
identical arguments. ``entra.group.delete`` soft-deletes a security group and hard-
|
|
33
|
+
deletes a distribution group. That is what authorize-phase gates exist for: they are
|
|
34
|
+
evaluated *before* the write, and a closed gate degrades the classification so policy
|
|
35
|
+
escalates while escalation still means something.
|
|
36
|
+
|
|
37
|
+
**Snapshot-before-write creates undo paths that do not otherwise exist.** A deleted
|
|
38
|
+
Kubernetes object, a deleted git branch, a force-pushed ref, an overwritten file, a
|
|
39
|
+
revoked security-group rule — none of these has a native undo, and all of them become
|
|
40
|
+
recoverable once prior state is captured. Roughly a third of the specs here are
|
|
41
|
+
recoverable *only* because of that ordering. It is the clearest statement of what this
|
|
42
|
+
architecture buys.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
from ..reversal.model import InverseSpec, ReversalGate, Reversibility
|
|
50
|
+
from ..reversal.registry import InverseRegistry
|
|
51
|
+
from . import cloud, database, devops, identity, saas, sap, workday, workstation
|
|
52
|
+
from .cloud import CLOUD_GATES, CLOUD_SPECS, cloud_registry
|
|
53
|
+
from .database import DATABASE_GATES, DATABASE_SPECS, database_registry
|
|
54
|
+
from .devops import DEVOPS_GATES, DEVOPS_SPECS, devops_registry
|
|
55
|
+
from .identity import IDENTITY_GATES, IDENTITY_SPECS, identity_registry
|
|
56
|
+
from .saas import SAAS_GATES, SAAS_SPECS, saas_registry
|
|
57
|
+
from .sap import SAP_GATES, SAP_SPECS, sap_registry
|
|
58
|
+
from .workday import WORKDAY_GATES, WORKDAY_SPECS, workday_registry
|
|
59
|
+
from .workstation import WORKSTATION_GATES, WORKSTATION_SPECS, workstation_registry
|
|
60
|
+
|
|
61
|
+
# Surface name -> (specs, gates). Ordered roughly by blast radius.
|
|
62
|
+
SURFACES: dict[str, tuple[list[InverseSpec], tuple[ReversalGate, ...]]] = {
|
|
63
|
+
"sap": (SAP_SPECS, SAP_GATES),
|
|
64
|
+
"workday": (WORKDAY_SPECS, WORKDAY_GATES),
|
|
65
|
+
"cloud": (CLOUD_SPECS, CLOUD_GATES),
|
|
66
|
+
"identity": (IDENTITY_SPECS, IDENTITY_GATES),
|
|
67
|
+
"devops": (DEVOPS_SPECS, DEVOPS_GATES),
|
|
68
|
+
"database": (DATABASE_SPECS, DATABASE_GATES),
|
|
69
|
+
"saas": (SAAS_SPECS, SAAS_GATES),
|
|
70
|
+
"workstation": (WORKSTATION_SPECS, WORKSTATION_GATES),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def all_specs(*surfaces: str) -> list[InverseSpec]:
|
|
75
|
+
"""Every spec across the named surfaces, or all of them if none are named."""
|
|
76
|
+
names = surfaces or tuple(SURFACES)
|
|
77
|
+
unknown = [n for n in names if n not in SURFACES]
|
|
78
|
+
if unknown:
|
|
79
|
+
raise KeyError(f"unknown surface(s) {unknown}; available: {sorted(SURFACES)}")
|
|
80
|
+
out: list[InverseSpec] = []
|
|
81
|
+
for name in names:
|
|
82
|
+
out.extend(SURFACES[name][0])
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def registry_for(*surfaces: str) -> InverseRegistry:
|
|
87
|
+
"""A combined registry for the named surfaces (all of them if none named).
|
|
88
|
+
|
|
89
|
+
Tool names are namespaced per surface, so combining is safe. Prefer loading
|
|
90
|
+
only the surfaces you actually govern: a registry claiming to classify SAP
|
|
91
|
+
postings in a shop with no SAP is noise in every coverage report.
|
|
92
|
+
"""
|
|
93
|
+
return InverseRegistry(all_specs(*surfaces))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def gate_catalog(*surfaces: str) -> dict[str, dict[str, Any]]:
|
|
97
|
+
"""Every gate across the named surfaces — the integrator's to-do list.
|
|
98
|
+
|
|
99
|
+
Each entry your ``GateEvaluator`` does not handle fails closed, so this is the
|
|
100
|
+
definitive list of questions you must be able to answer before the
|
|
101
|
+
corresponding specs can execute. ``phase`` matters: ``authorize`` gates change
|
|
102
|
+
whether an action is classified as undoable at all and are asked *before* the
|
|
103
|
+
write, while ``undo`` gates are asked immediately before a rollback runs.
|
|
104
|
+
"""
|
|
105
|
+
names = surfaces or tuple(SURFACES)
|
|
106
|
+
out: dict[str, dict[str, Any]] = {}
|
|
107
|
+
for name in names:
|
|
108
|
+
if name not in SURFACES:
|
|
109
|
+
raise KeyError(f"unknown surface {name!r}; available: {sorted(SURFACES)}")
|
|
110
|
+
for gate in SURFACES[name][1]:
|
|
111
|
+
out[gate.name] = {
|
|
112
|
+
"surface": name,
|
|
113
|
+
"phase": gate.check_at,
|
|
114
|
+
"description": gate.description,
|
|
115
|
+
"remediation": gate.remediation,
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def summary(*surfaces: str) -> dict[str, Any]:
|
|
121
|
+
"""Counts by reversal posture, plus the two figures worth reporting upward.
|
|
122
|
+
|
|
123
|
+
``snapshot_dependent`` is how many specs are recoverable only because prior
|
|
124
|
+
state is captured before the write — the share of the undo surface this
|
|
125
|
+
architecture creates rather than merely records.
|
|
126
|
+
|
|
127
|
+
``degradable`` is how many can turn out to be irreversible for a particular
|
|
128
|
+
target despite an optimistic classification. That gap is why authorize-phase
|
|
129
|
+
gates exist, and it is the number to watch: a spec that degrades and is never
|
|
130
|
+
checked is a phantom rollback waiting to happen.
|
|
131
|
+
"""
|
|
132
|
+
specs = all_specs(*surfaces)
|
|
133
|
+
by_kind: dict[str, int] = {k.value: 0 for k in Reversibility}
|
|
134
|
+
for s in specs:
|
|
135
|
+
by_kind[s.kind.value] += 1
|
|
136
|
+
return {
|
|
137
|
+
"surfaces": list(surfaces or tuple(SURFACES)),
|
|
138
|
+
"specs": len(specs),
|
|
139
|
+
"by_kind": by_kind,
|
|
140
|
+
"sequenced": len([s for s in specs if len(s.effective_steps) > 1]),
|
|
141
|
+
"one_shot": len([s for s in specs if s.one_shot]),
|
|
142
|
+
"gated": len([s for s in specs if s.gates]),
|
|
143
|
+
"degradable": len([s for s in specs if s.authorize_gates]),
|
|
144
|
+
"snapshot_dependent": len([s for s in specs if s.snapshot_fields]),
|
|
145
|
+
"gates": len(gate_catalog(*surfaces)),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = [
|
|
150
|
+
# modules
|
|
151
|
+
"sap", "workday", "cloud", "identity", "devops", "saas", "workstation", "database",
|
|
152
|
+
# per-surface
|
|
153
|
+
"SAP_SPECS", "SAP_GATES", "sap_registry",
|
|
154
|
+
"WORKDAY_SPECS", "WORKDAY_GATES", "workday_registry",
|
|
155
|
+
"CLOUD_SPECS", "CLOUD_GATES", "cloud_registry",
|
|
156
|
+
"IDENTITY_SPECS", "IDENTITY_GATES", "identity_registry",
|
|
157
|
+
"DEVOPS_SPECS", "DEVOPS_GATES", "devops_registry",
|
|
158
|
+
"SAAS_SPECS", "SAAS_GATES", "saas_registry",
|
|
159
|
+
"WORKSTATION_SPECS", "WORKSTATION_GATES", "workstation_registry",
|
|
160
|
+
"DATABASE_SPECS", "DATABASE_GATES", "database_registry",
|
|
161
|
+
# combined
|
|
162
|
+
"SURFACES", "all_specs", "registry_for", "gate_catalog", "summary",
|
|
163
|
+
]
|