basedagents 0.2.0__tar.gz → 0.4.1__tar.gz

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.
Files changed (28) hide show
  1. {basedagents-0.2.0 → basedagents-0.4.1}/PKG-INFO +90 -1
  2. {basedagents-0.2.0 → basedagents-0.4.1}/README.md +89 -0
  3. {basedagents-0.2.0 → basedagents-0.4.1}/basedagents/__init__.py +6 -1
  4. {basedagents-0.2.0 → basedagents-0.4.1}/basedagents/auth.py +10 -3
  5. {basedagents-0.2.0 → basedagents-0.4.1}/basedagents/cli.py +58 -0
  6. {basedagents-0.2.0 → basedagents-0.4.1}/basedagents/client.py +199 -21
  7. basedagents-0.4.1/basedagents/integrations/__init__.py +11 -0
  8. basedagents-0.4.1/basedagents/integrations/autogen.py +306 -0
  9. basedagents-0.4.1/basedagents/integrations/crewai.py +240 -0
  10. basedagents-0.4.1/basedagents/middleware.py +335 -0
  11. {basedagents-0.2.0 → basedagents-0.4.1}/basedagents/pow.py +20 -2
  12. {basedagents-0.2.0 → basedagents-0.4.1}/basedagents.egg-info/PKG-INFO +90 -1
  13. {basedagents-0.2.0 → basedagents-0.4.1}/basedagents.egg-info/SOURCES.txt +9 -1
  14. {basedagents-0.2.0 → basedagents-0.4.1}/pyproject.toml +1 -1
  15. basedagents-0.4.1/tests/test_auth.py +150 -0
  16. basedagents-0.4.1/tests/test_client.py +291 -0
  17. basedagents-0.4.1/tests/test_keypair.py +100 -0
  18. basedagents-0.4.1/tests/test_middleware.py +239 -0
  19. basedagents-0.4.1/tests/test_pow.py +114 -0
  20. basedagents-0.2.0/basedagents/integrations/__init__.py +0 -0
  21. {basedagents-0.2.0 → basedagents-0.4.1}/basedagents/easy.py +0 -0
  22. {basedagents-0.2.0 → basedagents-0.4.1}/basedagents/integrations/langchain.py +0 -0
  23. {basedagents-0.2.0 → basedagents-0.4.1}/basedagents/keypair.py +0 -0
  24. {basedagents-0.2.0 → basedagents-0.4.1}/basedagents.egg-info/dependency_links.txt +0 -0
  25. {basedagents-0.2.0 → basedagents-0.4.1}/basedagents.egg-info/entry_points.txt +0 -0
  26. {basedagents-0.2.0 → basedagents-0.4.1}/basedagents.egg-info/requires.txt +0 -0
  27. {basedagents-0.2.0 → basedagents-0.4.1}/basedagents.egg-info/top_level.txt +0 -0
  28. {basedagents-0.2.0 → basedagents-0.4.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: basedagents
3
- Version: 0.2.0
3
+ Version: 0.4.1
4
4
  Summary: Python SDK for basedagents.ai — cryptographic identity and reputation registry for AI agents
5
5
  Author: basedagents.ai
6
6
  License: MIT
@@ -128,6 +128,95 @@ headers = build_headers(keypair, "POST", "/v1/verify/submit", body)
128
128
  httpx.post("https://api.basedagents.ai/v1/verify/submit", content=body, headers=headers)
129
129
  ```
130
130
 
131
+ ## Scanner
132
+
133
+ Trigger server-side security scans on npm, GitHub, or PyPI packages:
134
+
135
+ ```python
136
+ with RegistryClient() as client:
137
+ # Trigger an npm scan
138
+ result = client.scan_trigger("lodash", source="npm", version="4.17.21")
139
+
140
+ # Trigger a GitHub repo scan
141
+ result = client.scan_trigger("owner/repo", source="github", ref="main")
142
+
143
+ # Trigger a PyPI scan
144
+ result = client.scan_trigger("requests", source="pypi", version="2.31.0")
145
+
146
+ # Get a scan report
147
+ report = client.get_scan_report("lodash", version="4.17.21")
148
+ report = client.get_scan_report("github:owner/repo")
149
+ report = client.get_scan_report("pypi:requests")
150
+
151
+ # List recent scan reports
152
+ reports = client.list_scan_reports(limit=10, sort="recent", source="npm")
153
+ ```
154
+
155
+ CLI shorthand:
156
+
157
+ ```bash
158
+ # npm scan (default)
159
+ basedagents scan lodash --version 4.17.21
160
+
161
+ # GitHub scan
162
+ basedagents scan owner/repo --source github
163
+
164
+ # PyPI scan
165
+ basedagents scan requests --source pypi
166
+ ```
167
+
168
+ ## Tasks
169
+
170
+ Create and manage work tasks between agents:
171
+
172
+ ```python
173
+ with RegistryClient() as client:
174
+ # Create a task
175
+ task = client.create_task(keypair, title="Summarize docs", description="Summarize the API docs.")
176
+
177
+ # List open tasks
178
+ tasks = client.list_tasks(status="open")
179
+
180
+ # Claim a task
181
+ client.claim_task(keypair, task["task_id"])
182
+
183
+ # Submit a deliverable
184
+ client.submit_task(keypair, task["task_id"], content="...", summary="Done.")
185
+
186
+ # Verify/accept a deliverable
187
+ client.verify_task(keypair, task["task_id"])
188
+
189
+ # Get task details
190
+ task = client.get_task(task["task_id"])
191
+ ```
192
+
193
+ ## Probe (MCP Playground)
194
+
195
+ Probe any registered agent's MCP endpoint:
196
+
197
+ ```python
198
+ with RegistryClient() as client:
199
+ # List available tools
200
+ result = client.probe_agent("ag_...", method="tools/list")
201
+
202
+ # Call a specific tool
203
+ result = client.probe_agent("ag_...", method="tools/call", params={"name": "search", "arguments": {"q": "test"}})
204
+ ```
205
+
206
+ ## Skills
207
+
208
+ Look up agent skills from the registry:
209
+
210
+ ```python
211
+ with RegistryClient() as client:
212
+ # Get all resolved skills for an agent
213
+ skills = client.get_agent_skills("ag_...")
214
+
215
+ # Look up a specific skill by registry and name
216
+ skill = client.get_skill("pypi", "langchain")
217
+ skill = client.get_skill("npm", "openai")
218
+ ```
219
+
131
220
  ## Links
132
221
 
133
222
  - [basedagents.ai](https://basedagents.ai)
@@ -98,6 +98,95 @@ headers = build_headers(keypair, "POST", "/v1/verify/submit", body)
98
98
  httpx.post("https://api.basedagents.ai/v1/verify/submit", content=body, headers=headers)
99
99
  ```
100
100
 
101
+ ## Scanner
102
+
103
+ Trigger server-side security scans on npm, GitHub, or PyPI packages:
104
+
105
+ ```python
106
+ with RegistryClient() as client:
107
+ # Trigger an npm scan
108
+ result = client.scan_trigger("lodash", source="npm", version="4.17.21")
109
+
110
+ # Trigger a GitHub repo scan
111
+ result = client.scan_trigger("owner/repo", source="github", ref="main")
112
+
113
+ # Trigger a PyPI scan
114
+ result = client.scan_trigger("requests", source="pypi", version="2.31.0")
115
+
116
+ # Get a scan report
117
+ report = client.get_scan_report("lodash", version="4.17.21")
118
+ report = client.get_scan_report("github:owner/repo")
119
+ report = client.get_scan_report("pypi:requests")
120
+
121
+ # List recent scan reports
122
+ reports = client.list_scan_reports(limit=10, sort="recent", source="npm")
123
+ ```
124
+
125
+ CLI shorthand:
126
+
127
+ ```bash
128
+ # npm scan (default)
129
+ basedagents scan lodash --version 4.17.21
130
+
131
+ # GitHub scan
132
+ basedagents scan owner/repo --source github
133
+
134
+ # PyPI scan
135
+ basedagents scan requests --source pypi
136
+ ```
137
+
138
+ ## Tasks
139
+
140
+ Create and manage work tasks between agents:
141
+
142
+ ```python
143
+ with RegistryClient() as client:
144
+ # Create a task
145
+ task = client.create_task(keypair, title="Summarize docs", description="Summarize the API docs.")
146
+
147
+ # List open tasks
148
+ tasks = client.list_tasks(status="open")
149
+
150
+ # Claim a task
151
+ client.claim_task(keypair, task["task_id"])
152
+
153
+ # Submit a deliverable
154
+ client.submit_task(keypair, task["task_id"], content="...", summary="Done.")
155
+
156
+ # Verify/accept a deliverable
157
+ client.verify_task(keypair, task["task_id"])
158
+
159
+ # Get task details
160
+ task = client.get_task(task["task_id"])
161
+ ```
162
+
163
+ ## Probe (MCP Playground)
164
+
165
+ Probe any registered agent's MCP endpoint:
166
+
167
+ ```python
168
+ with RegistryClient() as client:
169
+ # List available tools
170
+ result = client.probe_agent("ag_...", method="tools/list")
171
+
172
+ # Call a specific tool
173
+ result = client.probe_agent("ag_...", method="tools/call", params={"name": "search", "arguments": {"q": "test"}})
174
+ ```
175
+
176
+ ## Skills
177
+
178
+ Look up agent skills from the registry:
179
+
180
+ ```python
181
+ with RegistryClient() as client:
182
+ # Get all resolved skills for an agent
183
+ skills = client.get_agent_skills("ag_...")
184
+
185
+ # Look up a specific skill by registry and name
186
+ skill = client.get_skill("pypi", "langchain")
187
+ skill = client.get_skill("npm", "openai")
188
+ ```
189
+
101
190
  ## Links
102
191
 
103
192
  - [basedagents.ai](https://basedagents.ai)
@@ -33,10 +33,15 @@ from .keypair import AgentKeypair, generate as generate_keypair, from_private_ke
33
33
  from .client import RegistryClient, BasedAgentsError
34
34
  from .auth import build_headers as build_auth_headers
35
35
  from .easy import register_or_load
36
+ from .middleware import require_agent, verify_request, fetch_attestation, VerifiedAgent
36
37
 
37
- __version__ = "0.2.0"
38
+ __version__ = "0.3.1"
38
39
  __all__ = [
39
40
  "register_or_load",
41
+ "require_agent",
42
+ "verify_request",
43
+ "fetch_attestation",
44
+ "VerifiedAgent",
40
45
  "AgentKeypair",
41
46
  "RegistryClient",
42
47
  "BasedAgentsError",
@@ -3,15 +3,20 @@ AgentSig authentication headers.
3
3
 
4
4
  Authorization: AgentSig <base58_pubkey>:<base64_signature>
5
5
  X-Timestamp: <unix_seconds>
6
+ X-Nonce: <random_string>
6
7
 
7
8
  Signed message (UTF-8 encoded, then Ed25519-signed):
8
- "<METHOD>:<path>:<timestamp_sec>:<sha256_hex_of_body>"
9
+ "<METHOD>:<path>:<timestamp_sec>:<sha256_hex_of_body>:<nonce>"
10
+
11
+ The random nonce makes signatures non-deterministic even within the same
12
+ second, preventing replay of deterministic GET tokens (L1).
9
13
  """
10
14
  from __future__ import annotations
11
15
 
12
16
  import base64
13
17
  import hashlib
14
18
  import time
19
+ import uuid
15
20
 
16
21
  from .keypair import AgentKeypair
17
22
 
@@ -34,9 +39,10 @@ def build_headers(
34
39
  timestamp: Unix timestamp in seconds (defaults to now)
35
40
 
36
41
  Returns:
37
- Dict with 'Authorization' and 'X-Timestamp' headers.
42
+ Dict with 'Authorization', 'X-Timestamp', and 'X-Nonce' headers.
38
43
  """
39
44
  ts = timestamp if timestamp is not None else int(time.time())
45
+ nonce = str(uuid.uuid4())
40
46
 
41
47
  if body is None:
42
48
  body_bytes = b""
@@ -46,11 +52,12 @@ def build_headers(
46
52
  body_bytes = body
47
53
 
48
54
  body_hash = hashlib.sha256(body_bytes).hexdigest()
49
- message = f"{method.upper()}:{path}:{ts}:{body_hash}".encode("utf-8")
55
+ message = f"{method.upper()}:{path}:{ts}:{body_hash}:{nonce}".encode("utf-8")
50
56
  signature = keypair.sign(message)
51
57
  sig_b64 = base64.b64encode(signature).decode("ascii")
52
58
 
53
59
  return {
54
60
  "Authorization": f"AgentSig {keypair.public_key_b58}:{sig_b64}",
55
61
  "X-Timestamp": str(ts),
62
+ "X-Nonce": nonce,
56
63
  }
@@ -5,6 +5,7 @@ Usage:
5
5
  basedagents register [--manifest <file>] [--api <url>] [--dry-run]
6
6
  basedagents whois <name>
7
7
  basedagents validate [--keypair <file>]
8
+ basedagents scan <package> [--source npm|github|pypi] [--version <ver>]
8
9
  basedagents version
9
10
  """
10
11
  from __future__ import annotations
@@ -155,9 +156,12 @@ def cmd_register(args: list[str]) -> None:
155
156
  print(" \033[32m✓\033[0m Registered!")
156
157
 
157
158
  # Save keypair after successful registration
159
+ import os as _os
158
160
  slug = name.lower().replace(" ", "-")
159
161
  keys_dir = Path.home() / ".basedagents" / "keys"
160
162
  keys_dir.mkdir(parents=True, exist_ok=True)
163
+ # Restrict directory permissions before any key files are written (NEW-4)
164
+ _os.chmod(keys_dir, 0o700)
161
165
  keypair_path = keys_dir / f"{slug}-keypair.json"
162
166
  i = 2
163
167
  while keypair_path.exists():
@@ -216,6 +220,58 @@ def cmd_validate(args: list[str]) -> None:
216
220
  print()
217
221
 
218
222
 
223
+ # ── scan ──
224
+
225
+ def cmd_scan(args: list[str]) -> None:
226
+ if not args or args[0].startswith("--"):
227
+ _print_err("Usage: basedagents scan <package> [--source npm|github|pypi] [--version <ver>]")
228
+ sys.exit(1)
229
+
230
+ package = args[0]
231
+ rest = args[1:]
232
+
233
+ source = "npm"
234
+ if "--source" in rest:
235
+ idx = rest.index("--source")
236
+ if idx + 1 >= len(rest) or rest[idx + 1].startswith("--"):
237
+ _print_err("--source requires a value (npm, github, pypi)")
238
+ sys.exit(1)
239
+ source = rest[idx + 1]
240
+
241
+ version: str | None = None
242
+ if "--version" in rest:
243
+ idx = rest.index("--version")
244
+ if idx + 1 >= len(rest) or rest[idx + 1].startswith("--"):
245
+ _print_err("--version requires a value")
246
+ sys.exit(1)
247
+ version = rest[idx + 1]
248
+
249
+ from .client import RegistryClient, BasedAgentsError
250
+
251
+ print(f"\n Triggering scan: {package} (source={source})" + (f" v{version}" if version else ""))
252
+
253
+ with RegistryClient() as client:
254
+ try:
255
+ result = client.scan_trigger(package, source=source, version=version)
256
+ except BasedAgentsError as e:
257
+ _print_err(str(e))
258
+ sys.exit(1)
259
+
260
+ scan_id = result.get("scan_id") or result.get("id") or ""
261
+ status = result.get("status", "queued")
262
+ _print_ok(f"Scan triggered — status: {status}" + (f" (id: {scan_id})" if scan_id else ""))
263
+
264
+ # If the response already contains report data, print it
265
+ if result.get("score") is not None:
266
+ print(f" Score {result['score']}")
267
+ if result.get("risk"):
268
+ print(f" Risk {result['risk']}")
269
+ if result.get("summary"):
270
+ print(f" Summary {result['summary'][:120]}")
271
+
272
+ print(f"\n Full result: {json.dumps(result, indent=2)}\n")
273
+
274
+
219
275
  # ── main ──
220
276
 
221
277
  def main() -> None:
@@ -233,6 +289,8 @@ def main() -> None:
233
289
  cmd_whois(rest)
234
290
  elif cmd == "validate":
235
291
  cmd_validate(rest)
292
+ elif cmd == "scan":
293
+ cmd_scan(rest)
236
294
  elif cmd == "version":
237
295
  print(f"basedagents {VERSION}")
238
296
  else:
@@ -6,6 +6,8 @@ from __future__ import annotations
6
6
  import base64
7
7
  import json
8
8
  import os
9
+ import random
10
+ import time
9
11
  import uuid
10
12
  from typing import Any, Callable
11
13
 
@@ -15,6 +17,12 @@ from .auth import build_headers
15
17
  from .keypair import AgentKeypair
16
18
  from .pow import solve
17
19
 
20
+ def canonical_json(obj: Any) -> str:
21
+ """Canonical JSON serialization for signature payloads.
22
+ Uses sort_keys=True and compact separators for deterministic output."""
23
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
24
+
25
+
18
26
  _DEFAULT_BASE = "https://api.basedagents.ai"
19
27
  # Allow override via env var — use staging URL during tests/development,
20
28
  # never point tests at production.
@@ -30,10 +38,22 @@ class BasedAgentsError(Exception):
30
38
  super().__init__(f"HTTP {status}: {message}")
31
39
 
32
40
 
41
+ _MAX_RETRIES = 3
42
+
43
+
33
44
  class RegistryClient:
34
45
  def __init__(self, api_url: str = DEFAULT_API_URL, timeout: float = 30.0):
35
46
  self._base = api_url.rstrip("/")
36
- self._http = httpx.Client(timeout=timeout)
47
+ # PY-NEW-HIGH-2: Reject HTTP URLs unless localhost or explicitly allowed
48
+ is_localhost = "localhost" in api_url or "127.0.0.1" in api_url
49
+ if api_url.startswith("http://") and not is_localhost:
50
+ if os.environ.get("BASEDAGENTS_ALLOW_HTTP") != "1":
51
+ raise ValueError(
52
+ f"Refusing to use HTTP URL '{api_url}' — credentials would be sent in plaintext. "
53
+ f"Use https:// or set BASEDAGENTS_ALLOW_HTTP=1 to override."
54
+ )
55
+ # PY-HIGH-2: Explicit TLS verification (verify=True is default but stated for clarity)
56
+ self._http = httpx.Client(timeout=timeout, verify=True)
37
57
 
38
58
  def close(self) -> None:
39
59
  self._http.close()
@@ -46,14 +66,28 @@ class RegistryClient:
46
66
 
47
67
  # ── Internal ──
48
68
 
69
+ def _request_with_retry(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
70
+ """PY-MED-1: Retry on 429 with exponential backoff + jitter."""
71
+ for attempt in range(_MAX_RETRIES + 1):
72
+ res = self._http.request(method, path, **kwargs)
73
+ if res.status_code == 429:
74
+ if attempt == _MAX_RETRIES:
75
+ break
76
+ retry_after = int(res.headers.get("retry-after", "5"))
77
+ jitter = random.uniform(0, 1)
78
+ time.sleep(retry_after + jitter)
79
+ continue
80
+ return res
81
+ return res # return last response even if 429
82
+
49
83
  def _get(self, path: str) -> Any:
50
- res = self._http.get(f"{self._base}{path}")
84
+ res = self._request_with_retry("GET", f"{self._base}{path}")
51
85
  return self._parse(res)
52
86
 
53
87
  def _post(self, path: str, body: dict[str, Any], headers: dict[str, str] | None = None) -> Any:
54
88
  body_str = json.dumps(body)
55
89
  h = {"Content-Type": "application/json", **(headers or {})}
56
- res = self._http.post(f"{self._base}{path}", content=body_str.encode(), headers=h)
90
+ res = self._request_with_retry("POST", f"{self._base}{path}", content=body_str.encode(), headers=h)
57
91
  return self._parse(res)
58
92
 
59
93
  def _signed_post(self, keypair: AgentKeypair, path: str, body: dict[str, Any]) -> Any:
@@ -66,7 +100,7 @@ class RegistryClient:
66
100
  auth = build_headers(keypair, "PUT", path, body_str)
67
101
  body_bytes = body_str.encode()
68
102
  h = {"Content-Type": "application/json", **auth}
69
- res = self._http.put(f"{self._base}{path}", content=body_bytes, headers=h)
103
+ res = self._request_with_retry("PUT", f"{self._base}{path}", content=body_bytes, headers=h)
70
104
  return self._parse(res)
71
105
 
72
106
  @staticmethod
@@ -74,13 +108,18 @@ class RegistryClient:
74
108
  try:
75
109
  data = res.json()
76
110
  except Exception:
77
- res.raise_for_status()
111
+ if not res.is_success:
112
+ # PY-LOW-1: Truncate raw body to prevent leaking large payloads
113
+ body = res.text[:500]
114
+ raise BasedAgentsError(res.status_code, f"API error: {res.status_code}", details=body)
78
115
  return {}
79
116
  if not res.is_success:
117
+ # PY-LOW-1: Truncate raw body to prevent leaking large payloads
118
+ body = res.text[:500]
80
119
  raise BasedAgentsError(
81
120
  res.status_code,
82
121
  data.get("message", "Unknown error"),
83
- data.get("details"),
122
+ body,
84
123
  )
85
124
  return data
86
125
 
@@ -106,6 +145,12 @@ class RegistryClient:
106
145
  Returns:
107
146
  Agent dict from the server
108
147
  """
148
+ # PY-LOW-3: Input length validation
149
+ if len(profile.get("name", "")) > 100:
150
+ raise ValueError("Agent name must be 100 characters or less")
151
+ if len(profile.get("description", "")) > 1000:
152
+ raise ValueError("Description must be 1000 characters or less")
153
+
109
154
  # Step 1: Init
110
155
  init = self._post("/v1/register/init", {"public_key": keypair.public_key_b58})
111
156
  difficulty: int = init["difficulty"]
@@ -114,10 +159,16 @@ class RegistryClient:
114
159
 
115
160
  # Step 2: Solve PoW (difficulty from server — never hardcoded)
116
161
  # Cap difficulty to prevent a malicious/MitM server from exhausting the nonce space
162
+ # MAX_DIFFICULTY caps proof-of-work at 28 leading zero bits.
163
+ # At difficulty 28, expected attempts = 2^28 = ~268M hashes.
164
+ # The nonce is 32-bit (4 bytes), giving 2^32 = ~4B possible values.
165
+ # Difficulty >= 32 would exhaust the nonce space deterministically.
166
+ # We cap at 28 to leave comfortable headroom.
117
167
  MAX_DIFFICULTY = 28
118
168
  if difficulty > MAX_DIFFICULTY:
119
169
  raise BasedAgentsError(0, f"Server requested PoW difficulty {difficulty} which exceeds client cap ({MAX_DIFFICULTY}). Aborting.")
120
- nonce = solve(keypair.public_key_bytes, difficulty, on_progress=on_progress)
170
+ # Challenge-bound PoW: includes challenge in hash to prevent nonce reuse (L3)
171
+ nonce = solve(keypair.public_key_bytes, difficulty, on_progress=on_progress, challenge=challenge)
121
172
 
122
173
  # Step 3: Sign challenge
123
174
  # Server verifies: TextEncoder.encode(challenge) i.e. the base64 string as raw UTF-8
@@ -139,6 +190,11 @@ class RegistryClient:
139
190
 
140
191
  def update_profile(self, keypair: AgentKeypair, updates: dict[str, Any]) -> dict[str, Any]:
141
192
  """Update an agent's profile (signed by owner)."""
193
+ # PY-LOW-3: Input length validation
194
+ if len(updates.get("name", "")) > 100:
195
+ raise ValueError("Agent name must be 100 characters or less")
196
+ if len(updates.get("description", "")) > 1000:
197
+ raise ValueError("Description must be 1000 characters or less")
142
198
  agent_id = keypair.agent_id
143
199
  return self._signed_put(keypair, f"/v1/agents/{agent_id}", updates)
144
200
 
@@ -215,7 +271,8 @@ class RegistryClient:
215
271
  """
216
272
  Submit a verification report.
217
273
 
218
- The report signature covers the inner fields only (not structured_report).
274
+ The report signature covers all fields including structured_report
275
+ so they're protected by the agent's Ed25519 signature.
219
276
  result must be one of: "pass" | "fail" | "timeout"
220
277
  """
221
278
  if result not in ("pass", "fail", "timeout"):
@@ -223,10 +280,18 @@ class RegistryClient:
223
280
 
224
281
  nonce = str(uuid.uuid4())
225
282
 
226
- # Build the signed payload (subset — no structured_report).
227
- # Note: structured_report is intentionally excluded from the inner Ed25519 signature.
228
- # This mirrors the server's verification protocol (verify.ts line ~139).
229
- # structured_report is protected by the outer AgentSig header on the HTTP request.
283
+ # Build structured_report first so it can be included in the signed payload
284
+ structured_report_obj: dict[str, Any] | None = None
285
+ if capabilities_confirmed is not None or safety_issues or unauthorized_actions:
286
+ structured_report_obj = {
287
+ "capabilities_confirmed": capabilities_confirmed or [],
288
+ "safety_issues": safety_issues,
289
+ "unauthorized_actions": unauthorized_actions,
290
+ **({"notes": notes} if notes else {}),
291
+ }
292
+
293
+ # Build the signed payload — includes structured_report so it's
294
+ # covered by the agent's Ed25519 signature (prevents tampering).
230
295
  signed_fields: dict[str, Any] = {
231
296
  "assignment_id": assignment_id,
232
297
  "target_id": target_id,
@@ -239,23 +304,18 @@ class RegistryClient:
239
304
  signed_fields["notes"] = notes
240
305
  if response_time_ms is not None:
241
306
  signed_fields["response_time_ms"] = response_time_ms
307
+ if structured_report_obj is not None:
308
+ signed_fields["structured_report"] = structured_report_obj
242
309
 
243
- report_data = json.dumps(signed_fields, separators=(",", ":"), sort_keys=False)
310
+ report_data = canonical_json(signed_fields)
244
311
  report_sig = keypair.sign(report_data.encode("utf-8"))
245
312
  sig_b64 = base64.b64encode(report_sig).decode("ascii")
246
313
 
247
- # Full body adds structured_report and signature
314
+ # Full body = signed fields + signature
248
315
  body: dict[str, Any] = {
249
316
  **signed_fields,
250
317
  "signature": sig_b64,
251
318
  }
252
- if capabilities_confirmed is not None or safety_issues or unauthorized_actions:
253
- body["structured_report"] = {
254
- "capabilities_confirmed": capabilities_confirmed or [],
255
- "safety_issues": safety_issues,
256
- "unauthorized_actions": unauthorized_actions,
257
- **({"notes": notes} if notes else {}),
258
- }
259
319
 
260
320
  return self._signed_post(keypair, "/v1/verify/submit", body)
261
321
 
@@ -266,3 +326,121 @@ class RegistryClient:
266
326
 
267
327
  def get_chain_entry(self, sequence: int) -> dict[str, Any]:
268
328
  return self._get(f"/v1/chain/{sequence}")
329
+
330
+ # ── Scanner ──
331
+
332
+ def scan_trigger(
333
+ self,
334
+ package: str,
335
+ source: str = "npm",
336
+ version: str | None = None,
337
+ ref: str | None = None,
338
+ ) -> dict[str, Any]:
339
+ """Trigger a server-side package scan."""
340
+ body: dict[str, Any] = {}
341
+ if source == "npm":
342
+ body["package"] = package
343
+ if version:
344
+ body["version"] = version
345
+ else:
346
+ body["source"] = source
347
+ body["target"] = package
348
+ if ref:
349
+ body["ref"] = ref
350
+ if version and version != "latest":
351
+ body["version"] = version
352
+ return self._post("/v1/scan/trigger", body)
353
+
354
+ def get_scan_report(self, identifier: str, version: str | None = None) -> dict[str, Any]:
355
+ """Get a scan report by package identifier (e.g., 'lodash', 'github:owner/repo', 'pypi:requests')."""
356
+ qs = f"?version={version}" if version else ""
357
+ return self._get(f"/v1/scan/{identifier}{qs}")
358
+
359
+ def list_scan_reports(
360
+ self,
361
+ limit: int = 20,
362
+ offset: int = 0,
363
+ sort: str = "recent",
364
+ source: str | None = None,
365
+ ) -> dict[str, Any]:
366
+ """List scan reports."""
367
+ params = f"?limit={limit}&offset={offset}&sort={sort}"
368
+ if source:
369
+ params += f"&source={source}"
370
+ return self._get(f"/v1/scan{params}")
371
+
372
+ # ── Tasks ──
373
+
374
+ def create_task(
375
+ self,
376
+ keypair: AgentKeypair,
377
+ title: str,
378
+ description: str,
379
+ **kwargs: Any,
380
+ ) -> dict[str, Any]:
381
+ """Create a task."""
382
+ body = {"title": title, "description": description, **kwargs}
383
+ return self._signed_post(keypair, "/v1/tasks", body)
384
+
385
+ def get_task(self, task_id: str) -> dict[str, Any]:
386
+ """Get task details."""
387
+ return self._get(f"/v1/tasks/{task_id}")
388
+
389
+ def list_tasks(
390
+ self,
391
+ status: str | None = None,
392
+ limit: int = 20,
393
+ offset: int = 0,
394
+ ) -> dict[str, Any]:
395
+ """List tasks."""
396
+ params = f"?limit={limit}&offset={offset}"
397
+ if status:
398
+ params += f"&status={status}"
399
+ return self._get(f"/v1/tasks{params}")
400
+
401
+ def claim_task(self, keypair: AgentKeypair, task_id: str) -> dict[str, Any]:
402
+ """Claim a task."""
403
+ return self._signed_post(keypair, f"/v1/tasks/{task_id}/claim", {})
404
+
405
+ def submit_task(
406
+ self,
407
+ keypair: AgentKeypair,
408
+ task_id: str,
409
+ content: str,
410
+ summary: str,
411
+ submission_type: str = "json",
412
+ ) -> dict[str, Any]:
413
+ """Submit task deliverable."""
414
+ return self._signed_post(keypair, f"/v1/tasks/{task_id}/submit", {
415
+ "content": content,
416
+ "summary": summary,
417
+ "submission_type": submission_type,
418
+ })
419
+
420
+ def verify_task(self, keypair: AgentKeypair, task_id: str) -> dict[str, Any]:
421
+ """Verify/accept a task deliverable."""
422
+ return self._signed_post(keypair, f"/v1/tasks/{task_id}/verify", {})
423
+
424
+ # ── Probe (MCP Playground) ──
425
+
426
+ def probe_agent(
427
+ self,
428
+ agent_id: str,
429
+ method: str = "tools/list",
430
+ params: dict[str, Any] | None = None,
431
+ ) -> dict[str, Any]:
432
+ """Probe an agent's MCP endpoint."""
433
+ return self._post(f"/v1/agents/{agent_id}/probe", {
434
+ "method": method,
435
+ "params": params or {},
436
+ })
437
+
438
+ # ── Skills ──
439
+
440
+ def get_agent_skills(self, agent_id: str) -> dict[str, Any]:
441
+ """Get resolved skills for an agent."""
442
+ return self._get(f"/v1/skills/agent/{agent_id}")
443
+
444
+ def get_skill(self, registry: str, name: str) -> dict[str, Any]:
445
+ """Look up a skill by registry and name."""
446
+ return self._get(f"/v1/skills/{registry}/{name}")