tessera-proxy 0.2.0__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.
- tessera/__init__.py +143 -0
- tessera/capabilities.py +301 -0
- tessera/classification.py +293 -0
- tessera/cli.py +129 -0
- tessera/declassify.py +295 -0
- tessera/eval/__init__.py +42 -0
- tessera/eval/harness.py +285 -0
- tessera/eval/scenarios.py +312 -0
- tessera/integrations/__init__.py +8 -0
- tessera/integrations/agentdojo.py +244 -0
- tessera/labels.py +118 -0
- tessera/ledger.py +174 -0
- tessera/plan.py +275 -0
- tessera/planner.py +341 -0
- tessera/policy.py +194 -0
- tessera/provenance.py +173 -0
- tessera/proxy.py +257 -0
- tessera/py.typed +0 -0
- tessera/sanitize.py +157 -0
- tessera/sdk.py +248 -0
- tessera/session.py +617 -0
- tessera_proxy-0.2.0.dist-info/METADATA +416 -0
- tessera_proxy-0.2.0.dist-info/RECORD +26 -0
- tessera_proxy-0.2.0.dist-info/WHEEL +4 -0
- tessera_proxy-0.2.0.dist-info/entry_points.txt +2 -0
- tessera_proxy-0.2.0.dist-info/licenses/LICENSE +201 -0
tessera/__init__.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Tessera — a provenance control plane for tool-using agents.
|
|
2
|
+
|
|
3
|
+
Tessera contains the *blast radius* of a successful prompt injection. It does
|
|
4
|
+
not try to stop the model from being fooled (conceded as unsolvable in-band);
|
|
5
|
+
it ensures that when the model is fooled, the damage is bounded to actions that
|
|
6
|
+
are reversible, non-exfiltrating, or human-approved.
|
|
7
|
+
|
|
8
|
+
The v0.2 wedge — what this package implements — is a provenance-tracking MCP
|
|
9
|
+
proxy that:
|
|
10
|
+
|
|
11
|
+
1. labels every tool result by its trust origin (:mod:`tessera.labels`),
|
|
12
|
+
2. propagates those labels through an agent session (:mod:`tessera.session`),
|
|
13
|
+
3. classifies tools by blast radius (:mod:`tessera.classification`), and
|
|
14
|
+
4. enforces the single killer flow rule (:mod:`tessera.policy`):
|
|
15
|
+
|
|
16
|
+
Data that originated untrusted may not become an argument to an
|
|
17
|
+
exfiltration-capable or irreversible tool without passing a
|
|
18
|
+
declassifier or human approval.
|
|
19
|
+
|
|
20
|
+
Every label, decision, and escalation is written to an append-only audit
|
|
21
|
+
ledger (:mod:`tessera.ledger`).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from tessera.capabilities import (
|
|
25
|
+
Capability,
|
|
26
|
+
CapabilityEngine,
|
|
27
|
+
CapabilityResult,
|
|
28
|
+
Caveat,
|
|
29
|
+
arg_equals,
|
|
30
|
+
arg_in,
|
|
31
|
+
arg_matches,
|
|
32
|
+
expires_at,
|
|
33
|
+
max_uses,
|
|
34
|
+
tool_is,
|
|
35
|
+
)
|
|
36
|
+
from tessera.classification import (
|
|
37
|
+
BlastRadius,
|
|
38
|
+
Reversibility,
|
|
39
|
+
ToolProfile,
|
|
40
|
+
classify_tool,
|
|
41
|
+
)
|
|
42
|
+
from tessera.declassify import (
|
|
43
|
+
AllowlistDeclassifier,
|
|
44
|
+
BooleanDeclassifier,
|
|
45
|
+
Declassifier,
|
|
46
|
+
DeclassifyOutcome,
|
|
47
|
+
EnumDeclassifier,
|
|
48
|
+
IntegerDeclassifier,
|
|
49
|
+
IsoDateDeclassifier,
|
|
50
|
+
NumberDeclassifier,
|
|
51
|
+
PatternDeclassifier,
|
|
52
|
+
)
|
|
53
|
+
from tessera.labels import Origin, TrustLevel
|
|
54
|
+
from tessera.plan import (
|
|
55
|
+
Call,
|
|
56
|
+
Const,
|
|
57
|
+
Field,
|
|
58
|
+
Plan,
|
|
59
|
+
PlanInterpreter,
|
|
60
|
+
PlanRun,
|
|
61
|
+
Step,
|
|
62
|
+
Var,
|
|
63
|
+
call,
|
|
64
|
+
const,
|
|
65
|
+
plan,
|
|
66
|
+
step,
|
|
67
|
+
var,
|
|
68
|
+
)
|
|
69
|
+
from tessera.planner import (
|
|
70
|
+
ClaudePlanner,
|
|
71
|
+
Planner,
|
|
72
|
+
PlannerError,
|
|
73
|
+
ScriptedPlanner,
|
|
74
|
+
ToolSpec,
|
|
75
|
+
parse_plan,
|
|
76
|
+
plan_to_dict,
|
|
77
|
+
)
|
|
78
|
+
from tessera.policy import Decision, PolicyEngine, PolicyResult, Strictness
|
|
79
|
+
from tessera.provenance import LabeledValue, ProvenanceNode
|
|
80
|
+
from tessera.sdk import Blocked, Guard, protect, tool
|
|
81
|
+
from tessera.session import Session
|
|
82
|
+
|
|
83
|
+
__version__ = "0.2.0"
|
|
84
|
+
|
|
85
|
+
__all__ = [
|
|
86
|
+
"AllowlistDeclassifier",
|
|
87
|
+
"BlastRadius",
|
|
88
|
+
"Blocked",
|
|
89
|
+
"BooleanDeclassifier",
|
|
90
|
+
"Capability",
|
|
91
|
+
"CapabilityEngine",
|
|
92
|
+
"CapabilityResult",
|
|
93
|
+
"Caveat",
|
|
94
|
+
"Decision",
|
|
95
|
+
"Declassifier",
|
|
96
|
+
"DeclassifyOutcome",
|
|
97
|
+
"EnumDeclassifier",
|
|
98
|
+
"Guard",
|
|
99
|
+
"IntegerDeclassifier",
|
|
100
|
+
"IsoDateDeclassifier",
|
|
101
|
+
"LabeledValue",
|
|
102
|
+
"NumberDeclassifier",
|
|
103
|
+
"Call",
|
|
104
|
+
"Const",
|
|
105
|
+
"Field",
|
|
106
|
+
"ClaudePlanner",
|
|
107
|
+
"Origin",
|
|
108
|
+
"PatternDeclassifier",
|
|
109
|
+
"Plan",
|
|
110
|
+
"PlanInterpreter",
|
|
111
|
+
"PlanRun",
|
|
112
|
+
"Planner",
|
|
113
|
+
"PlannerError",
|
|
114
|
+
"PolicyEngine",
|
|
115
|
+
"PolicyResult",
|
|
116
|
+
"ScriptedPlanner",
|
|
117
|
+
"ToolSpec",
|
|
118
|
+
"parse_plan",
|
|
119
|
+
"plan_to_dict",
|
|
120
|
+
"ProvenanceNode",
|
|
121
|
+
"Reversibility",
|
|
122
|
+
"Session",
|
|
123
|
+
"Step",
|
|
124
|
+
"Strictness",
|
|
125
|
+
"ToolProfile",
|
|
126
|
+
"TrustLevel",
|
|
127
|
+
"Var",
|
|
128
|
+
"arg_equals",
|
|
129
|
+
"arg_in",
|
|
130
|
+
"arg_matches",
|
|
131
|
+
"call",
|
|
132
|
+
"classify_tool",
|
|
133
|
+
"const",
|
|
134
|
+
"expires_at",
|
|
135
|
+
"max_uses",
|
|
136
|
+
"plan",
|
|
137
|
+
"protect",
|
|
138
|
+
"step",
|
|
139
|
+
"tool",
|
|
140
|
+
"tool_is",
|
|
141
|
+
"var",
|
|
142
|
+
"__version__",
|
|
143
|
+
]
|
tessera/capabilities.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""The capability engine — kill ambient authority.
|
|
2
|
+
|
|
3
|
+
A classic agent holds a credential that works for *any* call to a tool: read
|
|
4
|
+
the inbox, send mail to anyone, delete any file. That ambient authority is what
|
|
5
|
+
makes a hijacked agent dangerous. Tessera replaces it with **capabilities**:
|
|
6
|
+
unforgeable, just-in-time, narrowly-scoped grants derived from the plan —
|
|
7
|
+
"send_email to bob@ with this payload", never "send_email to anyone". They
|
|
8
|
+
expire fast, and they **attenuate down delegation chains**: when one capability
|
|
9
|
+
is narrowed for a sub-agent or a downstream tool, permissions only ever shrink.
|
|
10
|
+
|
|
11
|
+
The construction is macaroon-style. A capability carries a list of **caveats**
|
|
12
|
+
(constraints) and an HMAC signature chained over them:
|
|
13
|
+
|
|
14
|
+
sig0 = HMAC(root_key, capability_id)
|
|
15
|
+
sig_i = HMAC(sig_{i-1}, serialize(caveat_i))
|
|
16
|
+
|
|
17
|
+
From this one trick the security properties fall out:
|
|
18
|
+
|
|
19
|
+
* **Unforgeable** — only a holder of ``root_key`` can mint a valid root
|
|
20
|
+
signature, so an agent cannot fabricate a capability from nothing.
|
|
21
|
+
* **Attenuation needs no secret, and can only narrow** — appending a caveat
|
|
22
|
+
and extending the chain (``HMAC(current_sig, new_caveat)``) requires no key,
|
|
23
|
+
but every caveat is an *additional* restriction; you can never drop or
|
|
24
|
+
reorder one without breaking the signature.
|
|
25
|
+
* **Verifiable** — the engine, holding ``root_key``, recomputes the whole
|
|
26
|
+
chain and checks every caveat against the proposed call.
|
|
27
|
+
|
|
28
|
+
``max_uses`` is inherently stateful, so the engine keeps a server-side use
|
|
29
|
+
counter keyed by capability id. Attenuated children keep their parent's id
|
|
30
|
+
(required so the HMAC chain still verifies from the root), which means a
|
|
31
|
+
capability lineage shares one use budget.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
import hmac
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import time
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from typing import Any, Mapping
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _hmac(key: bytes, msg: str) -> bytes:
|
|
46
|
+
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --------------------------------------------------------------------------
|
|
50
|
+
# Caveats
|
|
51
|
+
# --------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Caveat:
|
|
56
|
+
"""A single restriction on what a capability authorizes.
|
|
57
|
+
|
|
58
|
+
``kind`` selects the predicate; ``params`` carries its arguments. The
|
|
59
|
+
``serialize`` form is what gets folded into the HMAC chain, so it must be
|
|
60
|
+
canonical and stable.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
kind: str
|
|
64
|
+
params: tuple[tuple[str, Any], ...]
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def make(cls, kind: str, **params: Any) -> "Caveat":
|
|
68
|
+
return cls(kind=kind, params=tuple(sorted(params.items())))
|
|
69
|
+
|
|
70
|
+
def get(self, key: str) -> Any:
|
|
71
|
+
for k, v in self.params:
|
|
72
|
+
if k == key:
|
|
73
|
+
return v
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def serialize(self) -> str:
|
|
77
|
+
body = ",".join(f"{k}={v!r}" for k, v in self.params)
|
|
78
|
+
return f"{self.kind}({body})"
|
|
79
|
+
|
|
80
|
+
def check(self, tool: str, args: Mapping[str, Any], ctx: "_VerifyContext") -> tuple[bool, str]:
|
|
81
|
+
checker = _CAVEAT_CHECKS.get(self.kind)
|
|
82
|
+
if checker is None:
|
|
83
|
+
return False, f"unknown caveat kind {self.kind!r}"
|
|
84
|
+
return checker(self, tool, args, ctx)
|
|
85
|
+
|
|
86
|
+
def describe(self) -> str:
|
|
87
|
+
return self.serialize()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def tool_is(name: str) -> Caveat:
|
|
91
|
+
"""The call must target tool ``name``."""
|
|
92
|
+
return Caveat.make("tool_is", name=name)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def arg_equals(arg: str, value: Any) -> Caveat:
|
|
96
|
+
"""``args[arg]`` must equal ``value`` (compared as strings)."""
|
|
97
|
+
return Caveat.make("arg_equals", arg=arg, value=str(value))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def arg_matches(arg: str, pattern: str) -> Caveat:
|
|
101
|
+
"""``args[arg]`` must fully match the regex ``pattern``."""
|
|
102
|
+
return Caveat.make("arg_matches", arg=arg, pattern=pattern)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def arg_in(arg: str, values: list[str]) -> Caveat:
|
|
106
|
+
"""``args[arg]`` must be one of ``values``."""
|
|
107
|
+
return Caveat.make("arg_in", arg=arg, values="|".join(sorted(str(v) for v in values)))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def expires_at(epoch_seconds: float) -> Caveat:
|
|
111
|
+
"""The capability is invalid after ``epoch_seconds``."""
|
|
112
|
+
return Caveat.make("expires_at", ts=float(epoch_seconds))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def max_uses(n: int) -> Caveat:
|
|
116
|
+
"""The capability (lineage) may authorize at most ``n`` calls."""
|
|
117
|
+
return Caveat.make("max_uses", n=int(n))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _check_tool_is(c, tool, args, ctx):
|
|
121
|
+
want = c.get("name")
|
|
122
|
+
return (tool == want, f"tool must be {want!r}, got {tool!r}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _check_arg_equals(c, tool, args, ctx):
|
|
126
|
+
arg, want = c.get("arg"), c.get("value")
|
|
127
|
+
got = str(args.get(arg))
|
|
128
|
+
return (got == want, f"{arg} must equal {want!r}, got {got!r}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _check_arg_matches(c, tool, args, ctx):
|
|
132
|
+
arg, pattern = c.get("arg"), c.get("pattern")
|
|
133
|
+
got = str(args.get(arg, ""))
|
|
134
|
+
ok = re.fullmatch(pattern, got) is not None
|
|
135
|
+
return (ok, f"{arg}={got!r} must match /{pattern}/")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _check_arg_in(c, tool, args, ctx):
|
|
139
|
+
arg = c.get("arg")
|
|
140
|
+
allowed = set(c.get("values").split("|")) if c.get("values") else set()
|
|
141
|
+
got = str(args.get(arg))
|
|
142
|
+
return (got in allowed, f"{arg}={got!r} must be one of {sorted(allowed)}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _check_expires_at(c, tool, args, ctx):
|
|
146
|
+
ts = c.get("ts")
|
|
147
|
+
return (ctx.now <= ts, f"capability expired at {ts} (now {ctx.now:.0f})")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _check_max_uses(c, tool, args, ctx):
|
|
151
|
+
n = c.get("n")
|
|
152
|
+
return (ctx.uses < n, f"capability used {ctx.uses}/{n} times")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_CAVEAT_CHECKS = {
|
|
156
|
+
"tool_is": _check_tool_is,
|
|
157
|
+
"arg_equals": _check_arg_equals,
|
|
158
|
+
"arg_matches": _check_arg_matches,
|
|
159
|
+
"arg_in": _check_arg_in,
|
|
160
|
+
"expires_at": _check_expires_at,
|
|
161
|
+
"max_uses": _check_max_uses,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Aliases so the convenience minter can build caveats without its keyword
|
|
165
|
+
# parameters shadowing the constructor functions of the same name.
|
|
166
|
+
_cav_arg_equals = arg_equals
|
|
167
|
+
_cav_arg_in = arg_in
|
|
168
|
+
_cav_arg_matches = arg_matches
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --------------------------------------------------------------------------
|
|
172
|
+
# Capability
|
|
173
|
+
# --------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class Capability:
|
|
178
|
+
"""An unforgeable, attenuable grant. Identified by ``id``; the ``signature``
|
|
179
|
+
is the HMAC chain over ``caveats`` rooted at ``id``."""
|
|
180
|
+
|
|
181
|
+
id: str
|
|
182
|
+
caveats: tuple[Caveat, ...]
|
|
183
|
+
signature: bytes
|
|
184
|
+
|
|
185
|
+
def attenuate(self, *caveats: Caveat) -> "Capability":
|
|
186
|
+
"""Return a strictly narrower capability with extra caveats appended.
|
|
187
|
+
|
|
188
|
+
Requires no secret: the chain is extended from the current signature.
|
|
189
|
+
The result can only be *more* restricted than ``self``.
|
|
190
|
+
"""
|
|
191
|
+
sig = self.signature
|
|
192
|
+
for cav in caveats:
|
|
193
|
+
sig = _hmac(sig, cav.serialize())
|
|
194
|
+
return Capability(id=self.id, caveats=self.caveats + tuple(caveats), signature=sig)
|
|
195
|
+
|
|
196
|
+
def describe(self) -> str:
|
|
197
|
+
caveat_str = "; ".join(c.describe() for c in self.caveats) or "(no caveats)"
|
|
198
|
+
return f"cap {self.id[:8]} [{caveat_str}]"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class CapabilityResult:
|
|
203
|
+
"""The outcome of checking a capability against a proposed call."""
|
|
204
|
+
|
|
205
|
+
authorized: bool
|
|
206
|
+
reason: str
|
|
207
|
+
capability_id: str | None = None
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def denied(self) -> bool:
|
|
211
|
+
return not self.authorized
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass
|
|
215
|
+
class _VerifyContext:
|
|
216
|
+
now: float
|
|
217
|
+
uses: int
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# --------------------------------------------------------------------------
|
|
221
|
+
# Engine
|
|
222
|
+
# --------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass
|
|
226
|
+
class CapabilityEngine:
|
|
227
|
+
"""Mints, attenuates, and verifies capabilities against a root key."""
|
|
228
|
+
|
|
229
|
+
root_key: bytes = field(default_factory=lambda: os.urandom(32))
|
|
230
|
+
_uses: dict[str, int] = field(default_factory=dict)
|
|
231
|
+
|
|
232
|
+
def mint(self, *caveats: Caveat, capability_id: str | None = None) -> Capability:
|
|
233
|
+
"""Mint a fresh root capability with the given caveats (the secret path)."""
|
|
234
|
+
cap_id = capability_id or os.urandom(12).hex()
|
|
235
|
+
sig = _hmac(self.root_key, cap_id)
|
|
236
|
+
for cav in caveats:
|
|
237
|
+
sig = _hmac(sig, cav.serialize())
|
|
238
|
+
return Capability(id=cap_id, caveats=tuple(caveats), signature=sig)
|
|
239
|
+
|
|
240
|
+
def mint_for(
|
|
241
|
+
self,
|
|
242
|
+
tool: str,
|
|
243
|
+
*,
|
|
244
|
+
arg_equals: Mapping[str, Any] | None = None,
|
|
245
|
+
arg_in: Mapping[str, list[str]] | None = None,
|
|
246
|
+
arg_matches: Mapping[str, str] | None = None,
|
|
247
|
+
expires_in: float | None = None,
|
|
248
|
+
uses: int | None = None,
|
|
249
|
+
) -> Capability:
|
|
250
|
+
"""Convenience minter for the common 'one tool, scoped args' grant."""
|
|
251
|
+
caveats: list[Caveat] = [tool_is(tool)]
|
|
252
|
+
for k, v in (arg_equals or {}).items():
|
|
253
|
+
caveats.append(_cav_arg_equals(k, v))
|
|
254
|
+
for k, vals in (arg_in or {}).items():
|
|
255
|
+
caveats.append(_cav_arg_in(k, vals))
|
|
256
|
+
for k, pat in (arg_matches or {}).items():
|
|
257
|
+
caveats.append(_cav_arg_matches(k, pat))
|
|
258
|
+
if expires_in is not None:
|
|
259
|
+
caveats.append(expires_at(time.time() + expires_in))
|
|
260
|
+
if uses is not None:
|
|
261
|
+
caveats.append(max_uses(uses))
|
|
262
|
+
return self.mint(*caveats)
|
|
263
|
+
|
|
264
|
+
def _signature_valid(self, cap: Capability) -> bool:
|
|
265
|
+
expected = _hmac(self.root_key, cap.id)
|
|
266
|
+
for cav in cap.caveats:
|
|
267
|
+
expected = _hmac(expected, cav.serialize())
|
|
268
|
+
return hmac.compare_digest(expected, cap.signature)
|
|
269
|
+
|
|
270
|
+
def verify(
|
|
271
|
+
self,
|
|
272
|
+
cap: Capability,
|
|
273
|
+
tool: str,
|
|
274
|
+
args: Mapping[str, Any],
|
|
275
|
+
*,
|
|
276
|
+
now: float | None = None,
|
|
277
|
+
consume: bool = False,
|
|
278
|
+
) -> CapabilityResult:
|
|
279
|
+
"""Check that ``cap`` authorizes calling ``tool`` with ``args``.
|
|
280
|
+
|
|
281
|
+
Verifies the HMAC chain (unforgeability), then every caveat. With
|
|
282
|
+
``consume=True``, increments the use counter on success — call it only
|
|
283
|
+
for the capability you actually use.
|
|
284
|
+
"""
|
|
285
|
+
if not self._signature_valid(cap):
|
|
286
|
+
return CapabilityResult(False, "invalid signature (forged or tampered)", cap.id)
|
|
287
|
+
ctx = _VerifyContext(now=now if now is not None else time.time(), uses=self._uses.get(cap.id, 0))
|
|
288
|
+
for cav in cap.caveats:
|
|
289
|
+
ok, reason = cav.check(tool, args, ctx)
|
|
290
|
+
if not ok:
|
|
291
|
+
return CapabilityResult(False, reason, cap.id)
|
|
292
|
+
if consume:
|
|
293
|
+
self._uses[cap.id] = ctx.uses + 1
|
|
294
|
+
return CapabilityResult(True, "authorized by capability", cap.id)
|
|
295
|
+
|
|
296
|
+
def consume(self, cap: Capability) -> None:
|
|
297
|
+
"""Record one use of a capability lineage."""
|
|
298
|
+
self._uses[cap.id] = self._uses.get(cap.id, 0) + 1
|
|
299
|
+
|
|
300
|
+
def uses_of(self, cap: Capability) -> int:
|
|
301
|
+
return self._uses.get(cap.id, 0)
|