@agentlayer.tech/wallet 0.1.47 → 0.1.49

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.
@@ -18,6 +18,10 @@ const PREVIEW_BOUND_SWAP_TOOLS = new Set([
18
18
  "flash_trade_open_position",
19
19
  "flash_trade_close_position",
20
20
  ]);
21
+ const AUTONOMOUS_BASE_SWAP_TOOLS = new Set([
22
+ "swap_evm_tokens",
23
+ "swap_evm_uniswap_tokens",
24
+ ]);
21
25
  const approvalPreviewCache = new Map();
22
26
  const WALLET_TOOL_ONLY_GUIDANCE =
23
27
  "Use this wallet tool instead of shelling out to solana CLI, spl-token CLI, curl, or exec. If it fails, surface the wallet-tool error and stop rather than falling back to terminal commands.";
@@ -127,6 +131,14 @@ function requiresApprovedPreviewPayload(toolName, params = null) {
127
131
  return PREVIEW_BOUND_SWAP_TOOLS.has(toolName);
128
132
  }
129
133
 
134
+ function shouldLetBackendAuthorizeAutonomousBaseSwap(toolName, params, config) {
135
+ if (!AUTONOMOUS_BASE_SWAP_TOOLS.has(toolName)) return false;
136
+ if (String(params?.mode || "") !== "execute") return false;
137
+ if (typeof params?.approval_token === "string" && params.approval_token.trim()) return false;
138
+ const network = String(params?.network || config?.network || selectedEvmNetwork || "").trim().toLowerCase();
139
+ return network === "base";
140
+ }
141
+
130
142
  function looksLikeApprovalContextError(message) {
131
143
  const text = String(message || "").toLowerCase();
132
144
  return (
@@ -516,6 +528,7 @@ async function attachApprovalForExecute(api, config, userId, toolName, effective
516
528
  }
517
529
 
518
530
  if (effectiveParams.approval_token) return null;
531
+ if (shouldLetBackendAuthorizeAutonomousBaseSwap(toolName, effectiveParams, config)) return null;
519
532
 
520
533
  throw new Error(APPROVAL_CONTEXT_MISSING_MESSAGE);
521
534
  }
@@ -697,6 +710,38 @@ const walletSessionToolDefinitions = [
697
710
  additionalProperties: false,
698
711
  },
699
712
  },
713
+ {
714
+ name: "agentlayer_autonomous_status",
715
+ description: "Return AgentLayer high-trust autonomous permission status. Currently supports only scope=base_swaps.",
716
+ parameters: { type: "object", properties: {}, additionalProperties: false },
717
+ },
718
+ {
719
+ name: "agentlayer_autonomous_approve",
720
+ description:
721
+ "Enable high-trust autonomous Base swaps. scope=base_swaps lets Base Velora/Uniswap swap execute calls run without per-transaction human approval until revoked. This does not cover transfers, withdrawals, lending, staking, bridges, Solana swaps, or non-Base networks.",
722
+ parameters: {
723
+ type: "object",
724
+ properties: {
725
+ scope: { type: "string", enum: ["base_swaps"] },
726
+ purpose: { type: "string" },
727
+ user_intent: { type: "boolean" },
728
+ },
729
+ required: ["scope", "purpose", "user_intent"],
730
+ additionalProperties: false,
731
+ },
732
+ },
733
+ {
734
+ name: "agentlayer_autonomous_revoke",
735
+ description: "Disable high-trust autonomous execution for a scope. Currently supports only scope=base_swaps.",
736
+ parameters: {
737
+ type: "object",
738
+ properties: {
739
+ scope: { type: "string", enum: ["base_swaps"] },
740
+ },
741
+ required: ["scope"],
742
+ additionalProperties: false,
743
+ },
744
+ },
700
745
  {
701
746
  name: "x402_search_services",
702
747
  description:
@@ -2,9 +2,12 @@
2
2
  "id": "agent-wallet",
3
3
  "name": "Agent Wallet",
4
4
  "description": "Official OpenClaw plugin bridge for the agent-wallet backends, including Solana, local BTC, and local EVM.",
5
- "version": "0.1.47",
5
+ "version": "0.1.49",
6
6
  "contracts": {
7
7
  "tools": [
8
+ "agentlayer_autonomous_approve",
9
+ "agentlayer_autonomous_revoke",
10
+ "agentlayer_autonomous_status",
8
11
  "close_empty_token_accounts",
9
12
  "flash_trade_close_position",
10
13
  "flash_trade_open_position",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentlayertech/agent-wallet-plugin",
3
- "version": "0.1.47",
3
+ "version": "0.1.49",
4
4
  "description": "OpenClaw plugin bridge for the AgentLayer wallet runtime.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN ../../../LICENSE",
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.47
1
+ 0.1.49
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Keep in sync with package.json, pyproject.toml, and the npm installer version.
4
4
  # scripts/check_release_version.mjs enforces this on release.
5
- __version__ = "0.1.47"
5
+ __version__ = "0.1.49"
6
6
 
7
7
  __all__ = [
8
8
  "config",
@@ -0,0 +1,145 @@
1
+ """Persistent high-trust permission toggles for unattended wallet flows.
2
+
3
+ This module is intentionally narrower than ``autonomous_session``. It models
4
+ the "CLI permissions" UX where a user grants a standing capability, not a
5
+ budgeted policy envelope. The first supported scope is Base swaps only.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from agent_wallet.approval import issue_approval_token
16
+ from agent_wallet.config import resolve_openclaw_home
17
+ from agent_wallet.file_ops import atomic_write_text, chmod_if_exists
18
+ from agent_wallet.wallet_layer.base import WalletBackendError
19
+
20
+ PERMISSION_VERSION = 1
21
+ BASE_SWAP_SCOPE = "base_swaps"
22
+ BASE_SWAP_NETWORK = "base"
23
+ BASE_SWAP_TOOLS = frozenset({"swap_evm_tokens", "swap_evm_uniswap_tokens"})
24
+ BASE_SWAP_ISSUER = "autonomous-permission:base-swaps"
25
+ _PERMISSIONS_FILENAME = "autonomous_permissions.json"
26
+
27
+
28
+ def _permissions_path() -> Path:
29
+ return resolve_openclaw_home() / _PERMISSIONS_FILENAME
30
+
31
+
32
+ def _now() -> int:
33
+ return int(time.time())
34
+
35
+
36
+ def _empty_record() -> dict[str, Any]:
37
+ return {"version": PERMISSION_VERSION, "scopes": {}}
38
+
39
+
40
+ def _load_record() -> dict[str, Any]:
41
+ path = _permissions_path()
42
+ if not path.exists():
43
+ return _empty_record()
44
+ chmod_if_exists(path)
45
+ try:
46
+ record = json.loads(path.read_text(encoding="utf-8"))
47
+ except (OSError, ValueError):
48
+ return _empty_record()
49
+ if not isinstance(record, dict) or int(record.get("version") or 0) != PERMISSION_VERSION:
50
+ return _empty_record()
51
+ scopes = record.get("scopes")
52
+ if not isinstance(scopes, dict):
53
+ record["scopes"] = {}
54
+ return record
55
+
56
+
57
+ def _write_record(record: dict[str, Any]) -> None:
58
+ atomic_write_text(_permissions_path(), json.dumps(record, sort_keys=True, separators=(",", ":")))
59
+
60
+
61
+ def _scope_status(record: dict[str, Any], scope: str) -> dict[str, Any]:
62
+ raw = (record.get("scopes") or {}).get(scope)
63
+ if not isinstance(raw, dict):
64
+ return {"scope": scope, "enabled": False}
65
+ return {
66
+ "scope": scope,
67
+ "enabled": bool(raw.get("enabled")),
68
+ "approved_at": raw.get("approved_at"),
69
+ "approved_by": raw.get("approved_by"),
70
+ "network": raw.get("network"),
71
+ "tools": raw.get("tools") or [],
72
+ "warning": raw.get("warning"),
73
+ }
74
+
75
+
76
+ def status() -> dict[str, Any]:
77
+ """Return current high-trust autonomous permission status."""
78
+ record = _load_record()
79
+ base_swaps = _scope_status(record, BASE_SWAP_SCOPE)
80
+ return {
81
+ "active": bool(base_swaps.get("enabled")),
82
+ "scopes": {BASE_SWAP_SCOPE: base_swaps},
83
+ "permission_file": str(_permissions_path()),
84
+ }
85
+
86
+
87
+ def approve_base_swaps(*, approved_by: str = "user") -> dict[str, Any]:
88
+ """Enable unattended Base swap execution for supported EVM swap tools."""
89
+ record = _load_record()
90
+ scopes = record.setdefault("scopes", {})
91
+ scopes[BASE_SWAP_SCOPE] = {
92
+ "enabled": True,
93
+ "approved_at": _now(),
94
+ "approved_by": str(approved_by or "user"),
95
+ "network": BASE_SWAP_NETWORK,
96
+ "tools": sorted(BASE_SWAP_TOOLS),
97
+ "warning": (
98
+ "High-trust permission: Base swap execute calls can run without "
99
+ "per-transaction human approval until revoked."
100
+ ),
101
+ }
102
+ _write_record(record)
103
+ return status()
104
+
105
+
106
+ def revoke_base_swaps() -> dict[str, Any]:
107
+ """Disable unattended Base swap execution."""
108
+ record = _load_record()
109
+ scopes = record.setdefault("scopes", {})
110
+ existing = scopes.get(BASE_SWAP_SCOPE)
111
+ if isinstance(existing, dict):
112
+ existing["enabled"] = False
113
+ existing["revoked_at"] = _now()
114
+ else:
115
+ scopes[BASE_SWAP_SCOPE] = {"enabled": False, "revoked_at": _now()}
116
+ _write_record(record)
117
+ return status()
118
+
119
+
120
+ def is_base_swap_approved() -> bool:
121
+ return bool(status()["scopes"][BASE_SWAP_SCOPE].get("enabled"))
122
+
123
+
124
+ def authorize_base_swap(*, tool_name: str, network: str, summary: dict[str, Any]) -> str:
125
+ """Issue an internal approval token for one exact Base swap operation."""
126
+ if str(tool_name) not in BASE_SWAP_TOOLS:
127
+ raise WalletBackendError("Autonomous permission only covers Base swap tools.")
128
+ if str(network or "").strip().lower() != BASE_SWAP_NETWORK:
129
+ raise WalletBackendError("Autonomous Base swap permission only applies on network=base.")
130
+ if not is_base_swap_approved():
131
+ raise WalletBackendError(
132
+ "Autonomous Base swap permission is not enabled. Ask the user to run "
133
+ "agentlayer_autonomous_approve for scope=base_swaps first."
134
+ )
135
+ summary_network = str((summary or {}).get("network") or "").strip().lower()
136
+ if summary_network and summary_network != BASE_SWAP_NETWORK:
137
+ raise WalletBackendError("Autonomous Base swap summary is not bound to network=base.")
138
+ return issue_approval_token(
139
+ tool_name=tool_name,
140
+ network=BASE_SWAP_NETWORK,
141
+ summary=summary,
142
+ mainnet_confirmed=True,
143
+ ttl_seconds=120,
144
+ issued_by=BASE_SWAP_ISSUER,
145
+ )
@@ -0,0 +1,305 @@
1
+ """Autonomous execution policy engine for agent wallets.
2
+
3
+ The default execute path in :mod:`agent_wallet` is human-in-the-loop: the
4
+ host issues an approval token (see :mod:`agent_wallet.approval`) *after* a
5
+ person reviews the preview summary. This module adds an opt-in alternative
6
+ that lets an operation execute **without a human confirming each transaction**
7
+ — but only when it passes a deterministic risk gate that the operator
8
+ configures up front.
9
+
10
+ The design mirrors the "programmable controls set in advance" model used by
11
+ Coinbase AgentKit / CDP Agentic Wallets (session caps, per-transaction
12
+ limits, allow-lists) and the ``WalletProvider`` guard pattern: a thin policy
13
+ layer wraps the existing execute path instead of replacing it.
14
+
15
+ Key safety properties:
16
+
17
+ * **Deny by default.** A freshly constructed :class:`AutonomousSessionConfig`
18
+ approves nothing. Every capability (tool, network, recipient, spend
19
+ amount, mainnet access) must be explicitly enabled.
20
+ * **Same downstream verification.** When the gate passes, the engine issues
21
+ the *exact same* signed approval token the host would issue — only the
22
+ ``issued_by`` field differs (``"autonomous-policy"``). Nothing downstream
23
+ has to change or trust a new code path.
24
+ * **Bounded sessions.** Spend limits (reusing :class:`SpendingLedger`),
25
+ operation counts, and a session TTL bound the blast radius of a
26
+ compromised or misbehaving agent.
27
+
28
+ This module performs *no* network or signing I/O and has no dependency on a
29
+ live wallet backend, so it is cheap to unit-test in isolation.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import threading
35
+ import time
36
+ from dataclasses import dataclass, field
37
+ from typing import Callable
38
+
39
+ from agent_wallet.approval import issue_approval_token
40
+ from agent_wallet.spending_limits import SpendingConfig, SpendingLedger
41
+ from agent_wallet.wallet_layer.base import WalletBackendError
42
+
43
+ #: Issuer label stamped onto autonomously-approved tokens so audit logs can
44
+ #: distinguish them from host/human approvals.
45
+ AUTONOMOUS_ISSUER = "autonomous-policy"
46
+
47
+ #: Networks treated as "real money" and therefore gated behind
48
+ #: ``allow_mainnet`` regardless of the per-tool allow-list.
49
+ MAINNET_NETWORKS = frozenset({"mainnet", "mainnet-beta", "ethereum", "base", "arbitrum", "optimism", "polygon"})
50
+
51
+ TokenIssuer = Callable[..., str]
52
+
53
+
54
+ def _norm_network(network: str) -> str:
55
+ return str(network or "").strip().lower()
56
+
57
+
58
+ def _norm_addr(address: str | None) -> str:
59
+ """Normalize an address for allow-list comparison.
60
+
61
+ EVM ``0x`` addresses are case-insensitive, so they are lower-cased.
62
+ Other addresses (e.g. base58 Solana keys) are compared verbatim.
63
+ """
64
+ value = str(address or "").strip()
65
+ if value.lower().startswith("0x"):
66
+ return value.lower()
67
+ return value
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class AutonomousSessionConfig:
72
+ """Up-front authorization envelope for a single autonomous session.
73
+
74
+ All capabilities are off by default. A session that is not ``enabled``,
75
+ or whose allow-lists are empty, approves nothing.
76
+ """
77
+
78
+ enabled: bool = False
79
+ allowed_tools: frozenset[str] = frozenset()
80
+ allowed_networks: frozenset[str] = frozenset()
81
+ #: Mainnet is gated separately from the network allow-list so an operator
82
+ #: cannot enable "real money" execution by accident.
83
+ allow_mainnet: bool = False
84
+ #: Destination addresses / program ids the agent may interact with.
85
+ allowed_recipients: frozenset[str] = frozenset()
86
+ #: Escape hatch for flows where the destination is not known in advance
87
+ #: (e.g. router-built swaps). Spend caps and simulation still apply.
88
+ allow_any_recipient: bool = False
89
+ #: Require a passing simulation/dry-run before approving. Strongly
90
+ #: recommended; this is the autonomous analogue of a human eyeballing
91
+ #: the preview.
92
+ require_simulation: bool = True
93
+ #: Reused spend ledger configuration (per-tx / hourly / daily / rate).
94
+ spending: SpendingConfig = SpendingConfig()
95
+ #: Maximum number of operations the session may approve (0 = unlimited).
96
+ max_operations: int = 0
97
+ #: Session lifetime in seconds (0 = no expiry).
98
+ session_ttl_seconds: int = 0
99
+ #: TTL applied to each issued approval token.
100
+ approval_ttl_seconds: int = 120
101
+
102
+ def normalized(self) -> "AutonomousSessionConfig":
103
+ """Return a copy with networks/recipients normalized for matching."""
104
+ return AutonomousSessionConfig(
105
+ enabled=self.enabled,
106
+ allowed_tools=frozenset(str(t).strip() for t in self.allowed_tools),
107
+ allowed_networks=frozenset(_norm_network(n) for n in self.allowed_networks),
108
+ allow_mainnet=self.allow_mainnet,
109
+ allowed_recipients=frozenset(_norm_addr(a) for a in self.allowed_recipients),
110
+ allow_any_recipient=self.allow_any_recipient,
111
+ require_simulation=self.require_simulation,
112
+ spending=self.spending,
113
+ max_operations=self.max_operations,
114
+ session_ttl_seconds=self.session_ttl_seconds,
115
+ approval_ttl_seconds=self.approval_ttl_seconds,
116
+ )
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class OperationRequest:
121
+ """A single execute request submitted to the policy gate."""
122
+
123
+ tool_name: str
124
+ network: str
125
+ #: The confirmation summary that will be bound into the approval token.
126
+ #: It must be the same object the downstream verifier reconstructs.
127
+ summary: dict
128
+ #: Normalized notional spend used for limit accounting. Callers should
129
+ #: pass the outflow in the wallet's smallest unit (lamports/wei). Use 0
130
+ #: for read-shaped or zero-value operations, or ``None`` when the amount
131
+ #: could not be determined (the session layer treats unknown spend as a
132
+ #: hard denial whenever spend caps are configured).
133
+ spend_amount: int | None = 0
134
+ #: Destination address or program id, if known.
135
+ recipient: str | None = None
136
+ #: Whether a simulation/dry-run already succeeded for this operation.
137
+ simulated: bool = False
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class AutonomousDecision:
142
+ """Outcome of a policy evaluation."""
143
+
144
+ approved: bool
145
+ reason: str
146
+ rule: str | None = None
147
+ approval_token: str | None = None
148
+
149
+
150
+ @dataclass
151
+ class _SessionState:
152
+ started_at: float
153
+ operations: int = 0
154
+
155
+
156
+ class AutonomousPolicyEngine:
157
+ """Deterministic risk gate that programmatically issues approval tokens.
158
+
159
+ Construct one engine per autonomous session. Call :meth:`evaluate` for a
160
+ non-raising decision, or :meth:`authorize` to get a token back (raising
161
+ :class:`WalletBackendError` on denial, matching the rest of the wallet
162
+ layer's error contract).
163
+
164
+ ``token_issuer`` is injectable so tests can run without a sealed approval
165
+ secret; in production it defaults to the real
166
+ :func:`agent_wallet.approval.issue_approval_token`.
167
+ """
168
+
169
+ def __init__(
170
+ self,
171
+ config: AutonomousSessionConfig,
172
+ *,
173
+ token_issuer: TokenIssuer = issue_approval_token,
174
+ ledger: SpendingLedger | None = None,
175
+ clock: Callable[[], float] = time.time,
176
+ started_at: float | None = None,
177
+ operations_used: int = 0,
178
+ ) -> None:
179
+ self.config = config.normalized()
180
+ self._issuer = token_issuer
181
+ self._ledger = ledger if ledger is not None else SpendingLedger(self.config.spending)
182
+ self._clock = clock
183
+ self._lock = threading.Lock()
184
+ # ``started_at`` / ``operations_used`` are injectable so a session can
185
+ # be rehydrated from a persisted record across processes.
186
+ self._state = _SessionState(
187
+ started_at=clock() if started_at is None else started_at,
188
+ operations=int(operations_used),
189
+ )
190
+
191
+ # -- public API ----------------------------------------------------------
192
+
193
+ def evaluate(self, op: OperationRequest) -> AutonomousDecision:
194
+ """Evaluate *op* against the session policy without raising.
195
+
196
+ On approval the returned decision carries a freshly issued, signed
197
+ approval token bound to ``op.tool_name`` / ``op.network`` /
198
+ ``op.summary``.
199
+ """
200
+ cfg = self.config
201
+ network = _norm_network(op.network)
202
+
203
+ with self._lock:
204
+ # 1. Master switch.
205
+ if not cfg.enabled:
206
+ return self._deny("autonomous execution is disabled for this session", "enabled")
207
+
208
+ # 2. Session lifetime.
209
+ if cfg.session_ttl_seconds > 0:
210
+ age = self._clock() - self._state.started_at
211
+ if age > cfg.session_ttl_seconds:
212
+ return self._deny(
213
+ f"autonomous session expired ({age:.0f}s > {cfg.session_ttl_seconds}s)",
214
+ "session_ttl",
215
+ )
216
+
217
+ # 3. Operation budget.
218
+ if cfg.max_operations > 0 and self._state.operations >= cfg.max_operations:
219
+ return self._deny(
220
+ f"autonomous operation budget exhausted ({cfg.max_operations} ops)",
221
+ "max_operations",
222
+ )
223
+
224
+ # 4. Tool allow-list.
225
+ if op.tool_name not in cfg.allowed_tools:
226
+ return self._deny(f"tool '{op.tool_name}' is not autonomously allowed", "allowed_tools")
227
+
228
+ # 5. Network allow-list.
229
+ if network not in cfg.allowed_networks:
230
+ return self._deny(f"network '{network}' is not autonomously allowed", "allowed_networks")
231
+
232
+ # 6. Mainnet gate (separate from the network allow-list).
233
+ if network in MAINNET_NETWORKS and not cfg.allow_mainnet:
234
+ return self._deny(
235
+ f"mainnet network '{network}' requires allow_mainnet for autonomous execution",
236
+ "allow_mainnet",
237
+ )
238
+
239
+ # 7. Recipient allow-list.
240
+ if not cfg.allow_any_recipient:
241
+ recipient = _norm_addr(op.recipient)
242
+ if not recipient:
243
+ return self._deny("operation has no recipient and allow_any_recipient is false", "recipient")
244
+ if recipient not in cfg.allowed_recipients:
245
+ return self._deny(f"recipient '{op.recipient}' is not on the allow-list", "recipient")
246
+
247
+ # 8. Mandatory simulation.
248
+ if cfg.require_simulation and not op.simulated:
249
+ return self._deny("operation has no passing simulation and require_simulation is true", "simulation")
250
+
251
+ # 9. Spend limits (per-tx / rate / hourly / daily). Records on pass.
252
+ try:
253
+ self._ledger.check_and_record(int(op.spend_amount or 0))
254
+ except WalletBackendError as exc:
255
+ return self._deny(str(exc), "spending")
256
+
257
+ # 10. Issue the same signed token the host path would issue.
258
+ try:
259
+ token = self._issuer(
260
+ tool_name=op.tool_name,
261
+ network=op.network,
262
+ summary=op.summary,
263
+ mainnet_confirmed=network in MAINNET_NETWORKS and cfg.allow_mainnet,
264
+ ttl_seconds=cfg.approval_ttl_seconds,
265
+ issued_by=AUTONOMOUS_ISSUER,
266
+ )
267
+ except Exception as exc: # noqa: BLE001 - surface issuer failures verbatim
268
+ return self._deny(f"approval token issuance failed: {exc}", "issuer")
269
+
270
+ self._state.operations += 1
271
+ return AutonomousDecision(
272
+ approved=True,
273
+ reason="approved by autonomous policy",
274
+ rule="approved",
275
+ approval_token=token,
276
+ )
277
+
278
+ def authorize(self, op: OperationRequest) -> str:
279
+ """Like :meth:`evaluate` but return the token or raise on denial."""
280
+ decision = self.evaluate(op)
281
+ if not decision.approved or not decision.approval_token:
282
+ raise WalletBackendError(f"autonomous policy denied operation: {decision.reason}")
283
+ return decision.approval_token
284
+
285
+ def snapshot(self) -> dict:
286
+ """Return a JSON-serializable view of session state for auditing."""
287
+ with self._lock:
288
+ return {
289
+ "enabled": self.config.enabled,
290
+ "operations": self._state.operations,
291
+ "max_operations": self.config.max_operations,
292
+ "started_at": self._state.started_at,
293
+ "session_ttl_seconds": self.config.session_ttl_seconds,
294
+ "allow_mainnet": self.config.allow_mainnet,
295
+ }
296
+
297
+ def export_spend(self) -> list[tuple[float, int]]:
298
+ """Return the spend ledger entries so they can be persisted."""
299
+ return self._ledger.export()
300
+
301
+ # -- internal ------------------------------------------------------------
302
+
303
+ @staticmethod
304
+ def _deny(reason: str, rule: str) -> AutonomousDecision:
305
+ return AutonomousDecision(approved=False, reason=reason, rule=rule)