@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.
- package/.openclaw/extensions/agent-wallet/index.ts +45 -0
- package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +4 -1
- package/.openclaw/extensions/agent-wallet/package.json +1 -1
- package/VERSION +1 -1
- package/agent-wallet/agent_wallet/__init__.py +1 -1
- package/agent-wallet/agent_wallet/autonomous_permissions.py +145 -0
- package/agent-wallet/agent_wallet/autonomous_policy.py +305 -0
- package/agent-wallet/agent_wallet/autonomous_session.py +236 -0
- package/agent-wallet/agent_wallet/openclaw_adapter.py +682 -43
- package/agent-wallet/agent_wallet/openclaw_cli.py +36 -0
- package/agent-wallet/agent_wallet/spending_limits.py +21 -3
- package/agent-wallet/agent_wallet/wallet_layer/base.py +110 -0
- package/agent-wallet/openclaw.plugin.json +1 -1
- package/agent-wallet/pyproject.toml +1 -1
- package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
- package/claude-code/plugins/agent-wallet/README.md +5 -0
- package/claude-code/plugins/agent-wallet/commands/agentlayer-autonomous-approve.md +24 -0
- package/claude-code/plugins/agent-wallet/commands/agentlayer-autonomous-revoke.md +16 -0
- package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/server.py +18 -0
- package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
- package/package.json +1 -1
- package/wdk-btc-wallet/package.json +1 -1
- package/wdk-evm-wallet/package.json +1 -1
|
@@ -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.
|
|
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",
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.49
|
|
@@ -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)
|