dockerhub-api 1.3.10__tar.gz → 1.4.0__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 (74) hide show
  1. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/PKG-INFO +4 -10
  2. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/README.md +2 -8
  3. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/api/api_client_access_tokens.py +3 -1
  4. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/api/api_client_base.py +1 -20
  5. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/api/api_client_groups.py +4 -2
  6. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/api/api_client_org_access_tokens.py +3 -1
  7. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/api/api_client_orgs.py +5 -3
  8. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/api/api_client_registry.py +11 -9
  9. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/api/api_client_registry_base.py +0 -2
  10. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/auth.py +4 -17
  11. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/mcp/__init__.py +0 -3
  12. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/ontology/certification.json +3 -3
  13. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/ontology/dockerhub.ttl +4 -1
  14. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/ontology/shapes/connector.shacl.ttl +2 -2
  15. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api.egg-info/PKG-INFO +4 -10
  16. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api.egg-info/SOURCES.txt +1 -0
  17. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api.egg-info/requires.txt +1 -1
  18. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/pyproject.toml +2 -2
  19. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_api_org.py +6 -16
  20. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_api_registry.py +11 -24
  21. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_api_teams.py +4 -9
  22. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_api_tokens.py +4 -12
  23. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_api_wrapper.py +2 -8
  24. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_auth.py +0 -6
  25. dockerhub_api-1.4.0/tests/test_destructive_declaration.py +98 -0
  26. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/LICENSE +0 -0
  27. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/__init__.py +0 -0
  28. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/__main__.py +0 -0
  29. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/api/__init__.py +0 -0
  30. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/api/api_client_audit_logs.py +0 -0
  31. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/api/api_client_auth.py +0 -0
  32. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/api/api_client_repositories.py +0 -0
  33. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/api/api_client_scim.py +0 -0
  34. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/api/api_client_scout.py +0 -0
  35. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/api_client.py +0 -0
  36. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/connector_contracts.py +0 -0
  37. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/connector_sources.py +0 -0
  38. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/dockerhub_input_models.py +0 -0
  39. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/dockerhub_response_models.py +0 -0
  40. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/kg_ingest.py +0 -0
  41. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/mcp/mcp_admin.py +0 -0
  42. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/mcp/mcp_kg.py +0 -0
  43. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/mcp_config.json +0 -0
  44. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/mcp_server.py +0 -0
  45. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/ontology/__init__.py +0 -0
  46. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/ontology/fixtures/records.json +0 -0
  47. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/ontology/mappings/source.yaml +0 -0
  48. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/ontology/migrations/manifest.json +0 -0
  49. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/prompts/dockerhub-mcp-operator.json +0 -0
  50. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/prompts/dockerhub-mcp-organization-administrator.json +0 -0
  51. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/prompts/dockerhub-mcp-release-engineer.json +0 -0
  52. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/prompts/dockerhub-mcp-security-analyst.json +0 -0
  53. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/skills/dockerhub-api-development/SKILL.md +0 -0
  54. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/skills/dockerhub-api-operations/SKILL.md +0 -0
  55. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/skills/dockerhub-image-registry-scout/SKILL.md +0 -0
  56. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/skills/dockerhub-org-administration/SKILL.md +0 -0
  57. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api/skills/dockerhub-repository-management/SKILL.md +0 -0
  58. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api.egg-info/dependency_links.txt +0 -0
  59. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api.egg-info/entry_points.txt +0 -0
  60. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/dockerhub_api.egg-info/top_level.txt +0 -0
  61. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/setup.cfg +0 -0
  62. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_api_audit.py +0 -0
  63. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_api_repositories.py +0 -0
  64. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_api_scim.py +0 -0
  65. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_api_scout.py +0 -0
  66. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_dockerhub_a2a_validation.py +0 -0
  67. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_dockerhub_mcp_validation.py +0 -0
  68. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_fleet_name.py +0 -0
  69. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_identity_namespace_scoping.py +0 -0
  70. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_init_dynamics.py +0 -0
  71. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_kg_ingest.py +0 -0
  72. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_models.py +0 -0
  73. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_rate_limit.py +0 -0
  74. {dockerhub_api-1.3.10 → dockerhub_api-1.4.0}/tests/test_startup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dockerhub-api
3
- Version: 1.3.10
3
+ Version: 1.4.0
4
4
  Summary: Docker Hub API + MCP Server
5
5
  Author-email: Repository Maintainers <maintainers@knuckles.team>
6
6
  License: MIT
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: <3.15,>=3.12
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
- Requires-Dist: scion-sdk[mcp]>=0.9.0
15
+ Requires-Dist: scion-sdk[mcp]<0.11,>=0.10.2
16
16
  Requires-Dist: httpx>=0.28.1
17
17
  Requires-Dist: python-dotenv>=1.2.4
18
18
  Provides-Extra: test
@@ -84,8 +84,7 @@ multi-arch inspection, OCI referrers, and gated push/remove) and **Docker Scout*
84
84
  expiry, with one transparent re-mint on 401.
85
85
  - **Rate-Limit Telemetry:** `X-RateLimit-*` headers surfaced in every result;
86
86
  HTTP 429 retried with bounded `Retry-After` backoff.
87
- - **Safety by Default:** Removes and org-settings writes are gated behind
88
- `DOCKERHUB_ALLOW_DESTRUCTIVE` (default `False`); secrets are redacted from tool
87
+ - **Safety by Default:** Removes and org-settings writes are declared destructive; the SDK asks the caller to confirm (`confirm=true`) before any request. Secrets are redacted from tool
89
88
  results (plaintext tokens appear exactly once — on creation). Repository creation
90
89
  stays enabled: it is the primary release-provisioning use case.
91
90
  - **Integrated Graph Agent:** Built-in Pydantic AI agent (`dockerhub-agent`) with
@@ -279,7 +278,6 @@ _9 action-routed tool(s) (default) · 54 verbose 1:1 tool(s). Each is enabled un
279
278
  "ADMINTOOL": "True",
280
279
  "AUDITTOOL": "True",
281
280
  "AUTHTOOL": "True",
282
- "DOCKERHUB_ALLOW_DESTRUCTIVE": "False",
283
281
  "DOCKERHUB_TLS_PROFILE": "system",
284
282
  "DOCKERHUB_TOKEN": "dckr_pat_your_personal_access_token",
285
283
  "DOCKERHUB_URL": "https://hub.docker.com",
@@ -328,7 +326,6 @@ own runtime secret boundary.
328
326
  "ADMINTOOL": "True",
329
327
  "AUDITTOOL": "True",
330
328
  "AUTHTOOL": "True",
331
- "DOCKERHUB_ALLOW_DESTRUCTIVE": "False",
332
329
  "DOCKERHUB_TLS_PROFILE": "system",
333
330
  "DOCKERHUB_TOKEN": "dckr_pat_your_personal_access_token",
334
331
  "DOCKERHUB_URL": "https://hub.docker.com",
@@ -376,7 +373,6 @@ docker run -i --rm \
376
373
  -e ADMINTOOL=True \
377
374
  -e AUDITTOOL=True \
378
375
  -e AUTHTOOL=True \
379
- -e DOCKERHUB_ALLOW_DESTRUCTIVE=False \
380
376
  -e DOCKERHUB_TLS_PROFILE=system \
381
377
  -e DOCKERHUB_TOKEN=dckr_pat_your_personal_access_token \
382
378
  -e DOCKERHUB_URL=https://hub.docker.com \
@@ -423,8 +419,7 @@ the detailed transport contract.
423
419
  |---|---|---|
424
420
  | Reads (repos, tags, members, logs, SCIM) | allowed | — |
425
421
  | Repository create / immutable-tag config / invites / role updates | allowed | — |
426
- | Removes (PATs, OATs, groups, members, invites) | **blocked** | `DOCKERHUB_ALLOW_DESTRUCTIVE=True` |
427
- | Org-settings writes (`PUT /v2/orgs/{org}/settings`) | **blocked** | `DOCKERHUB_ALLOW_DESTRUCTIVE=True` |
422
+ | Removes (PATs, OATs, groups, members, invites) and org-settings writes | confirmation required | `confirm=true` per call, or operator setting `SCION_ALLOW_DESTRUCTIVE` |
428
423
 
429
424
  ---
430
425
 
@@ -488,7 +483,6 @@ network transport, connector ingestion, Arbora delegation, or trace export.
488
483
  | `DOCKER_REGISTRY_URL` | `https://registry-1.docker.io` | |
489
484
  | `DOCKER_REGISTRY_AUTH_URL` | `https://auth.docker.io/token` | token-service realm; auto-discovered from the 401 challenge |
490
485
  | `DOCKER_SCOUT_URL` | `https://api.scout.docker.com` | |
491
- | `DOCKERHUB_ALLOW_DESTRUCTIVE` | `False` | |
492
486
  | `AUTHTOOL` | `True` | |
493
487
  | `REPOSTOOL` | `True` | |
494
488
  | `ORGTOOL` | `True` | |
@@ -60,8 +60,7 @@ multi-arch inspection, OCI referrers, and gated push/remove) and **Docker Scout*
60
60
  expiry, with one transparent re-mint on 401.
61
61
  - **Rate-Limit Telemetry:** `X-RateLimit-*` headers surfaced in every result;
62
62
  HTTP 429 retried with bounded `Retry-After` backoff.
63
- - **Safety by Default:** Removes and org-settings writes are gated behind
64
- `DOCKERHUB_ALLOW_DESTRUCTIVE` (default `False`); secrets are redacted from tool
63
+ - **Safety by Default:** Removes and org-settings writes are declared destructive; the SDK asks the caller to confirm (`confirm=true`) before any request. Secrets are redacted from tool
65
64
  results (plaintext tokens appear exactly once — on creation). Repository creation
66
65
  stays enabled: it is the primary release-provisioning use case.
67
66
  - **Integrated Graph Agent:** Built-in Pydantic AI agent (`dockerhub-agent`) with
@@ -255,7 +254,6 @@ _9 action-routed tool(s) (default) · 54 verbose 1:1 tool(s). Each is enabled un
255
254
  "ADMINTOOL": "True",
256
255
  "AUDITTOOL": "True",
257
256
  "AUTHTOOL": "True",
258
- "DOCKERHUB_ALLOW_DESTRUCTIVE": "False",
259
257
  "DOCKERHUB_TLS_PROFILE": "system",
260
258
  "DOCKERHUB_TOKEN": "dckr_pat_your_personal_access_token",
261
259
  "DOCKERHUB_URL": "https://hub.docker.com",
@@ -304,7 +302,6 @@ own runtime secret boundary.
304
302
  "ADMINTOOL": "True",
305
303
  "AUDITTOOL": "True",
306
304
  "AUTHTOOL": "True",
307
- "DOCKERHUB_ALLOW_DESTRUCTIVE": "False",
308
305
  "DOCKERHUB_TLS_PROFILE": "system",
309
306
  "DOCKERHUB_TOKEN": "dckr_pat_your_personal_access_token",
310
307
  "DOCKERHUB_URL": "https://hub.docker.com",
@@ -352,7 +349,6 @@ docker run -i --rm \
352
349
  -e ADMINTOOL=True \
353
350
  -e AUDITTOOL=True \
354
351
  -e AUTHTOOL=True \
355
- -e DOCKERHUB_ALLOW_DESTRUCTIVE=False \
356
352
  -e DOCKERHUB_TLS_PROFILE=system \
357
353
  -e DOCKERHUB_TOKEN=dckr_pat_your_personal_access_token \
358
354
  -e DOCKERHUB_URL=https://hub.docker.com \
@@ -399,8 +395,7 @@ the detailed transport contract.
399
395
  |---|---|---|
400
396
  | Reads (repos, tags, members, logs, SCIM) | allowed | — |
401
397
  | Repository create / immutable-tag config / invites / role updates | allowed | — |
402
- | Removes (PATs, OATs, groups, members, invites) | **blocked** | `DOCKERHUB_ALLOW_DESTRUCTIVE=True` |
403
- | Org-settings writes (`PUT /v2/orgs/{org}/settings`) | **blocked** | `DOCKERHUB_ALLOW_DESTRUCTIVE=True` |
398
+ | Removes (PATs, OATs, groups, members, invites) and org-settings writes | confirmation required | `confirm=true` per call, or operator setting `SCION_ALLOW_DESTRUCTIVE` |
404
399
 
405
400
  ---
406
401
 
@@ -464,7 +459,6 @@ network transport, connector ingestion, Arbora delegation, or trace export.
464
459
  | `DOCKER_REGISTRY_URL` | `https://registry-1.docker.io` | |
465
460
  | `DOCKER_REGISTRY_AUTH_URL` | `https://auth.docker.io/token` | token-service realm; auto-discovered from the 401 challenge |
466
461
  | `DOCKER_SCOUT_URL` | `https://api.scout.docker.com` | |
467
- | `DOCKERHUB_ALLOW_DESTRUCTIVE` | `False` | |
468
462
  | `AUTHTOOL` | `True` | |
469
463
  | `REPOSTOOL` | `True` | |
470
464
  | `ORGTOOL` | `True` | |
@@ -5,6 +5,8 @@ CONCEPT:DH-OS.identity.jwt-auth-lifecycle-endpoint — JWT auth lifecycle (PAT m
5
5
 
6
6
  from typing import Any
7
7
 
8
+ from scion_sdk.mcp.destructive import destructive_operation
9
+
8
10
  from dockerhub_api.api.api_client_base import DockerHubApiBase
9
11
  from dockerhub_api.dockerhub_input_models import (
10
12
  AccessTokenCreateModel,
@@ -70,8 +72,8 @@ class DockerHubApiAccessTokens(DockerHubApiBase):
70
72
  envelope["data"] = validate_lenient(AccessToken, envelope["data"])
71
73
  return envelope
72
74
 
75
+ @destructive_operation
73
76
  def delete_access_token(self, uuid: str) -> dict[str, Any]:
74
77
  """Delete a personal access token. Destructive — gated."""
75
- self._guard_destructive("delete_access_token")
76
78
  model = AccessTokenModel(uuid=uuid)
77
79
  return self._request("DELETE", f"/v2/access-tokens/{model.uuid}")
@@ -10,8 +10,7 @@ headers are captured into the result envelope (and kept on the client as
10
10
  ``Retry-After`` backoff.
11
11
 
12
12
  CONCEPT:DH-OS.identity.destructive-action-gating-member — destructive-action gating. Deletes and org-settings writes
13
- raise :class:`DestructiveOperationError` unless the client was built with
14
- ``allow_destructive=True``.
13
+ are declared with the SDK ``destructive_operation`` decorator; the SDK asks the caller to confirm.
15
14
  """
16
15
 
17
16
  import logging
@@ -40,10 +39,6 @@ DEFAULT_MAX_RETRIES = 3
40
39
  DEFAULT_TIMEOUT = 30.0
41
40
 
42
41
 
43
- class DestructiveOperationError(PermissionError):
44
- """Raised when a destructive operation is attempted while gated off."""
45
-
46
-
47
42
  class DockerHubApiBase:
48
43
  """Shared transport, auth header, retry, and envelope logic."""
49
44
 
@@ -58,7 +53,6 @@ class DockerHubApiBase:
58
53
  timeout: float = DEFAULT_TIMEOUT,
59
54
  max_retries: int = DEFAULT_MAX_RETRIES,
60
55
  retry_after_cap: float = DEFAULT_RETRY_AFTER_CAP,
61
- allow_destructive: bool = False,
62
56
  debug: bool = False,
63
57
  transport: httpx.BaseTransport | None = None,
64
58
  ):
@@ -75,7 +69,6 @@ class DockerHubApiBase:
75
69
  self.timeout = timeout
76
70
  self.max_retries = max_retries
77
71
  self.retry_after_cap = retry_after_cap
78
- self.allow_destructive = allow_destructive
79
72
  self.debug = debug
80
73
  #: Last rate-limit snapshot seen on any response.
81
74
  self.rate_limit: dict[str, Any] = {}
@@ -188,18 +181,6 @@ class DockerHubApiBase:
188
181
  "rate_limit": dict(self.rate_limit) if self.rate_limit else {},
189
182
  }
190
183
 
191
- # ------------------------------------------------------------------ #
192
- # Destructive gating
193
- # ------------------------------------------------------------------ #
194
-
195
- def _guard_destructive(self, operation: str) -> None:
196
- if not self.allow_destructive:
197
- raise DestructiveOperationError(
198
- f"Destructive operation '{operation}' is disabled. "
199
- "Build the client with allow_destructive=True or set "
200
- "DOCKERHUB_ALLOW_DESTRUCTIVE=True to enable it."
201
- )
202
-
203
184
  # ------------------------------------------------------------------ #
204
185
  # Request engine
205
186
  # ------------------------------------------------------------------ #
@@ -6,6 +6,8 @@ CONCEPT:DH-OS.identity.destructive-action-gating-member — destructive-action g
6
6
 
7
7
  from typing import Any
8
8
 
9
+ from scion_sdk.mcp.destructive import destructive_operation
10
+
9
11
  from dockerhub_api.api.api_client_base import DockerHubApiBase
10
12
  from dockerhub_api.dockerhub_input_models import (
11
13
  GroupCreateModel,
@@ -99,9 +101,9 @@ class DockerHubApiGroups(DockerHubApiBase):
99
101
  envelope["data"] = validate_lenient(Group, envelope["data"])
100
102
  return envelope
101
103
 
104
+ @destructive_operation
102
105
  def delete_group(self, org: str, group_name: str) -> dict[str, Any]:
103
106
  """Delete a group. Destructive — gated."""
104
- self._guard_destructive("delete_group")
105
107
  model = GroupModel(org=org, group_name=group_name)
106
108
  return self._request(
107
109
  "DELETE", f"/v2/orgs/{model.org}/groups/{model.group_name}"
@@ -146,11 +148,11 @@ class DockerHubApiGroups(DockerHubApiBase):
146
148
  envelope["data"] = validate_lenient(OrgMember, envelope["data"])
147
149
  return envelope
148
150
 
151
+ @destructive_operation
149
152
  def remove_group_member(
150
153
  self, org: str, group_name: str, username: str
151
154
  ) -> dict[str, Any]:
152
155
  """Remove a username from a group. Destructive — gated."""
153
- self._guard_destructive("remove_group_member")
154
156
  model = GroupMemberModel(org=org, group_name=group_name, username=username)
155
157
  return self._request(
156
158
  "DELETE",
@@ -5,6 +5,8 @@ CONCEPT:DH-OS.identity.jwt-auth-lifecycle-endpoint — JWT auth lifecycle (OAT m
5
5
 
6
6
  from typing import Any
7
7
 
8
+ from scion_sdk.mcp.destructive import destructive_operation
9
+
8
10
  from dockerhub_api.api.api_client_base import DockerHubApiBase
9
11
  from dockerhub_api.dockerhub_input_models import (
10
12
  OrgAccessTokenCreateModel,
@@ -97,9 +99,9 @@ class DockerHubApiOrgAccessTokens(DockerHubApiBase):
97
99
  envelope["data"] = validate_lenient(OrgAccessToken, envelope["data"])
98
100
  return envelope
99
101
 
102
+ @destructive_operation
100
103
  def delete_org_access_token(self, org: str, token_id: str) -> dict[str, Any]:
101
104
  """Delete an organization access token. Destructive — gated."""
102
- self._guard_destructive("delete_org_access_token")
103
105
  model = OrgAccessTokenModel(org=org, token_id=token_id)
104
106
  return self._request(
105
107
  "DELETE", f"/v2/orgs/{model.org}/access-tokens/{model.token_id}"
@@ -7,6 +7,8 @@ org-settings writes).
7
7
 
8
8
  from typing import Any
9
9
 
10
+ from scion_sdk.mcp.destructive import destructive_operation
11
+
10
12
  from dockerhub_api.api.api_client_base import DockerHubApiBase
11
13
  from dockerhub_api.dockerhub_input_models import (
12
14
  BulkInviteModel,
@@ -37,6 +39,7 @@ class DockerHubApiOrgs(DockerHubApiBase):
37
39
  envelope["data"] = validate_lenient(OrgSettings, envelope["data"])
38
40
  return envelope
39
41
 
42
+ @destructive_operation
40
43
  def update_org_settings(
41
44
  self,
42
45
  org: str,
@@ -50,7 +53,6 @@ class DockerHubApiOrgs(DockerHubApiBase):
50
53
  org-approved images, with carve-outs for Docker Official Images and
51
54
  Verified Publisher images.
52
55
  """
53
- self._guard_destructive("update_org_settings")
54
56
  model = OrgSettingsModel(
55
57
  org=org,
56
58
  restricted_images_enabled=restricted_images_enabled,
@@ -104,9 +106,9 @@ class DockerHubApiOrgs(DockerHubApiBase):
104
106
  envelope["data"] = validate_lenient(OrgMember, envelope["data"])
105
107
  return envelope
106
108
 
109
+ @destructive_operation
107
110
  def remove_org_member(self, org: str, username: str) -> dict[str, Any]:
108
111
  """Remove a member from the organization. Destructive — gated."""
109
- self._guard_destructive("remove_org_member")
110
112
  model = OrgMemberModel(org=org, username=username)
111
113
  return self._request("DELETE", f"/v2/orgs/{model.org}/members/{model.username}")
112
114
 
@@ -118,9 +120,9 @@ class DockerHubApiOrgs(DockerHubApiBase):
118
120
  envelope["data"] = validate_lenient(InvitePage, envelope["data"])
119
121
  return envelope
120
122
 
123
+ @destructive_operation
121
124
  def delete_invite(self, invite_id: str) -> dict[str, Any]:
122
125
  """Cancel an invite. Destructive — gated."""
123
- self._guard_destructive("delete_invite")
124
126
  model = InviteModel(invite_id=invite_id)
125
127
  return self._request("DELETE", f"/v2/invites/{model.invite_id}")
126
128
 
@@ -6,8 +6,8 @@ multi-arch platform resolution, digest resolution, and (gated) deletes.
6
6
  CONCEPT:DH-OS.governance.oci-referrers-attestation-discovery — OCI Referrers / attestation discovery.
7
7
  CONCEPT:DH-OS.governance.chunked-blob-push-upload — chunked blob push (upload session + manifest put).
8
8
 
9
- Pushes, deletes, and blob uploads are destructive and gated by
10
- ``allow_destructive`` (``DOCKERHUB_ALLOW_DESTRUCTIVE``). ``_catalog`` is
9
+ Pushes, deletes, and blob uploads are destructive: the SDK
10
+ asks the caller to confirm each. ``_catalog`` is
11
11
  intentionally omitted: Docker Hub does not support registry-wide catalog
12
12
  listing (it requires a registry-scoped token the Hub token service won't issue).
13
13
  """
@@ -15,6 +15,8 @@ listing (it requires a registry-scoped token the Hub token service won't issue).
15
15
  import json as _json
16
16
  from typing import Any
17
17
 
18
+ from scion_sdk.mcp.destructive import destructive_operation
19
+
18
20
  from dockerhub_api.api.api_client_registry_base import (
19
21
  ACCEPT_MANIFESTS,
20
22
  OCTET_STREAM,
@@ -96,6 +98,7 @@ class RegistryApi(RegistryApiBase):
96
98
  }
97
99
  return envelope
98
100
 
101
+ @destructive_operation
99
102
  def put_manifest(
100
103
  self,
101
104
  repo: str,
@@ -104,7 +107,6 @@ class RegistryApi(RegistryApiBase):
104
107
  media_type: str,
105
108
  ) -> dict[str, Any]:
106
109
  """``PUT /v2/{repo}/manifests/{reference}`` — push a manifest (gated)."""
107
- self._guard_destructive("put_manifest")
108
110
  body = manifest if isinstance(manifest, str) else _json.dumps(manifest)
109
111
  return self._registry_request(
110
112
  "PUT",
@@ -115,13 +117,13 @@ class RegistryApi(RegistryApiBase):
115
117
  content_type=media_type,
116
118
  )
117
119
 
120
+ @destructive_operation
118
121
  def delete_manifest(self, repo: str, reference: str) -> dict[str, Any]:
119
122
  """``DELETE /v2/{repo}/manifests/{digest}`` — delete a tag/manifest (gated).
120
123
 
121
124
  Docker Hub requires deletion by digest; pass a tag and it is resolved
122
125
  to its digest first.
123
126
  """
124
- self._guard_destructive("delete_manifest")
125
127
  target = reference
126
128
  if not reference.startswith("sha256:"):
127
129
  resolved = self.resolve_digest(repo, reference)
@@ -140,13 +142,13 @@ class RegistryApi(RegistryApiBase):
140
142
  """``HEAD /v2/{repo}/blobs/{digest}`` — blob existence check."""
141
143
  return self._registry_exists(repo, f"/blobs/{digest}")
142
144
 
145
+ @destructive_operation
143
146
  def delete_blob(self, repo: str, digest: str) -> dict[str, Any]:
144
147
  """``DELETE /v2/{repo}/blobs/{digest}`` — delete a blob (gated).
145
148
 
146
149
  Often returns ``405`` on Docker Hub (blob delete disabled); the status
147
150
  is surfaced cleanly rather than masked.
148
151
  """
149
- self._guard_destructive("delete_blob")
150
152
  return self._registry_request(
151
153
  "DELETE",
152
154
  repo,
@@ -293,13 +295,14 @@ class RegistryApi(RegistryApiBase):
293
295
 
294
296
  # ------------------------------- push -------------------------------- #
295
297
 
298
+ @destructive_operation
296
299
  def start_upload(self, repo: str) -> dict[str, Any]:
297
300
  """``POST /v2/{repo}/blobs/uploads/`` — open a blob upload session (gated)."""
298
- self._guard_destructive("start_upload")
299
301
  return self._registry_request(
300
302
  "POST", repo, "/blobs/uploads/", scope_actions="pull,push"
301
303
  )
302
304
 
305
+ @destructive_operation
303
306
  def upload_chunk(
304
307
  self,
305
308
  repo: str,
@@ -308,7 +311,6 @@ class RegistryApi(RegistryApiBase):
308
311
  content_range: str | None = None,
309
312
  ) -> dict[str, Any]:
310
313
  """``PATCH {location}`` — upload one blob chunk (gated)."""
311
- self._guard_destructive("upload_chunk")
312
314
  extra = {"Content-Range": content_range} if content_range else None
313
315
  return self._registry_request(
314
316
  "PATCH",
@@ -320,6 +322,7 @@ class RegistryApi(RegistryApiBase):
320
322
  extra_headers=extra,
321
323
  )
322
324
 
325
+ @destructive_operation
323
326
  def complete_upload(
324
327
  self,
325
328
  repo: str,
@@ -328,7 +331,6 @@ class RegistryApi(RegistryApiBase):
328
331
  final_chunk: bytes | None = None,
329
332
  ) -> dict[str, Any]:
330
333
  """``PUT {location}?digest=`` — finalize a blob upload (gated)."""
331
- self._guard_destructive("complete_upload")
332
334
  return self._registry_request(
333
335
  "PUT",
334
336
  repo,
@@ -339,9 +341,9 @@ class RegistryApi(RegistryApiBase):
339
341
  content_type=OCTET_STREAM,
340
342
  )
341
343
 
344
+ @destructive_operation
342
345
  def mount_blob(self, repo: str, digest: str, from_repo: str) -> dict[str, Any]:
343
346
  """``POST /v2/{repo}/blobs/uploads/?mount=&from=`` — cross-repo mount (gated)."""
344
- self._guard_destructive("mount_blob")
345
347
  return self._registry_request(
346
348
  "POST",
347
349
  repo,
@@ -74,7 +74,6 @@ class RegistryApiBase(DockerHubApiBase):
74
74
  registry_token_manager: Any | None = None,
75
75
  tls_profile: ResolvedTLSProfile | None = None,
76
76
  timeout: float | None = None,
77
- allow_destructive: bool = False,
78
77
  debug: bool = False,
79
78
  transport: httpx.BaseTransport | None = None,
80
79
  **kwargs: Any,
@@ -85,7 +84,6 @@ class RegistryApiBase(DockerHubApiBase):
85
84
  url=url or DEFAULT_REGISTRY_URL,
86
85
  tls_profile=tls_profile,
87
86
  timeout=timeout if timeout is not None else 30.0,
88
- allow_destructive=allow_destructive,
89
87
  debug=debug,
90
88
  transport=transport,
91
89
  **kwargs,
@@ -339,12 +339,6 @@ def _resolve_tls_profile(
339
339
  )
340
340
 
341
341
 
342
- def _resolve_allow_destructive(allow_destructive: bool | None) -> bool:
343
- if allow_destructive is None:
344
- return setting("DOCKERHUB_ALLOW_DESTRUCTIVE", False)
345
- return allow_destructive
346
-
347
-
348
342
  @dataclass
349
343
  class _HubCredentials:
350
344
  url: str
@@ -407,7 +401,6 @@ def get_client(
407
401
  token: str | None = None,
408
402
  jwt: str | None = None,
409
403
  tls_profile: ResolvedTLSProfile | None = None,
410
- allow_destructive: bool | None = None,
411
404
  config: dict | None = None,
412
405
  ) -> Any:
413
406
  """Factory for the Docker Hub :class:`~dockerhub_api.api_client.Api` client.
@@ -422,8 +415,8 @@ def get_client(
422
415
  password, a PAT ``dckr_pat_*``, or an org token).
423
416
  3. Anonymous — public, unauthenticated endpoints only.
424
417
 
425
- ``DOCKERHUB_ALLOW_DESTRUCTIVE`` (default ``False``) gates deletes and
426
- org-settings writes. CONCEPT:DH-OS.identity.destructive-action-gating-member — destructive-action gating.
418
+ Deletes and org-settings writes are declared destructive with the SDK and ask the caller to confirm.
419
+ CONCEPT:DH-OS.identity.destructive-action-gating-member — destructive-action gating.
427
420
 
428
421
  A resolved ``username`` (personal account or org) the caller's identity is
429
422
  not entitled to is denied before any credential exchange happens.
@@ -433,7 +426,6 @@ def get_client(
433
426
  creds = _resolve_hub_credentials(url, username, token, jwt, config)
434
427
  _deny_unless_entitled(creds.username)
435
428
  profile = _resolve_tls_profile(tls_profile)
436
- allow_destructive = _resolve_allow_destructive(allow_destructive)
437
429
 
438
430
  if creds.jwt:
439
431
  logger.info("Using pre-minted Docker Hub JWT")
@@ -441,7 +433,6 @@ def get_client(
441
433
  url=creds.url,
442
434
  token=creds.jwt,
443
435
  tls_profile=profile,
444
- allow_destructive=allow_destructive,
445
436
  )
446
437
 
447
438
  if creds.username and creds.token:
@@ -459,14 +450,13 @@ def get_client(
459
450
  url=creds.url,
460
451
  token_manager=manager,
461
452
  tls_profile=profile,
462
- allow_destructive=allow_destructive,
463
453
  )
464
454
 
465
455
  logger.warning(
466
456
  "No Docker Hub credentials configured — anonymous client "
467
457
  "(public endpoints only). Set DOCKERHUB_USERNAME and DOCKERHUB_TOKEN."
468
458
  )
469
- return Api(url=creds.url, tls_profile=profile, allow_destructive=allow_destructive)
459
+ return Api(url=creds.url, tls_profile=profile)
470
460
 
471
461
 
472
462
  def _resolve_credentials(
@@ -509,7 +499,6 @@ def get_registry_client(
509
499
  username: str | None = None,
510
500
  token: str | None = None,
511
501
  tls_profile: ResolvedTLSProfile | None = None,
512
- allow_destructive: bool | None = None,
513
502
  config: dict | None = None,
514
503
  transport: Any | None = None,
515
504
  ) -> Any:
@@ -519,7 +508,7 @@ def get_registry_client(
519
508
  scoped tokens via :class:`RegistryTokenManager` using the same
520
509
  ``DOCKER_HUB_USER`` / ``DOCKER_HUB_TOKEN`` credentials (anonymous when
521
510
  unset, which still works for public pulls). Pushes, deletes, and blob
522
- uploads are gated by ``DOCKERHUB_ALLOW_DESTRUCTIVE``.
511
+ uploads are declared destructive with the SDK.
523
512
 
524
513
  A resolved ``username`` (personal account or org) the caller's identity is
525
514
  not entitled to is denied before any token is minted.
@@ -541,7 +530,6 @@ def get_registry_client(
541
530
  username, token = _resolve_credentials(username, token, config)
542
531
  _deny_unless_entitled(username)
543
532
  profile = _resolve_tls_profile(tls_profile)
544
- allow_destructive = _resolve_allow_destructive(allow_destructive)
545
533
 
546
534
  token_manager = RegistryTokenManager(
547
535
  username=username,
@@ -555,7 +543,6 @@ def get_registry_client(
555
543
  url=url,
556
544
  registry_token_manager=token_manager,
557
545
  tls_profile=profile,
558
- allow_destructive=allow_destructive,
559
546
  transport=transport,
560
547
  )
561
548
 
@@ -15,8 +15,6 @@ from scion_sdk.exceptions import (
15
15
  UnauthorizedError,
16
16
  )
17
17
 
18
- from dockerhub_api.api.api_client_base import DestructiveOperationError
19
-
20
18
  #: Keys whose values are always masked in MCP tool results.
21
19
  SECRET_KEYS = {"secret", "password", "access_token", "refresh_token", "jwt"}
22
20
  REDACTED = "***redacted***"
@@ -76,7 +74,6 @@ HANDLED_ERRORS = (
76
74
  UnauthorizedError,
77
75
  ParameterError,
78
76
  MissingParameterError,
79
- DestructiveOperationError,
80
77
  ValueError,
81
78
  )
82
79
 
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "api_version": "graphos.io/v1",
3
3
  "artifacts": {
4
- "dockerhub_api/ontology/dockerhub.ttl": "04f8546a078e863362b02e29aeb0625414c955394ce0884df5b4726fed6205e3",
4
+ "dockerhub_api/ontology/dockerhub.ttl": "d498f4e75964cdb27a76fddccb23e42556e7eee994d8d91ba773c0ed2cc9dc5e",
5
5
  "dockerhub_api/ontology/fixtures/records.json": "cdf24bdcd53b75daef789c05f93d55ced4ce4a138360a2ff9f1e34c55f8f6da8",
6
6
  "dockerhub_api/ontology/mappings/source.yaml": "84985121b0fe97a045b04a5c40183d434d3d7ca1bca29202080cb390955545a6",
7
7
  "dockerhub_api/ontology/migrations/manifest.json": "81a24f182615e83dc844c6e7f3a211d47705d2bfce4ee45b5c4b24134e64909f",
8
- "dockerhub_api/ontology/shapes/connector.shacl.ttl": "202ba0d2203b8a638b9bb035af8454ab6ea536e9d6d5a54f521c770b2dacc2ef"
8
+ "dockerhub_api/ontology/shapes/connector.shacl.ttl": "1417340d744e6cf9488ab231ff0ff21ed79cf126f54a901966da4827aa41d05b"
9
9
  },
10
10
  "checks": {
11
11
  "declared_tool_schema": "passed",
@@ -29,5 +29,5 @@
29
29
  "signer": "UNSIGNED-PREVIEW",
30
30
  "signing_public_key": "",
31
31
  "status": "source-validated",
32
- "validated_at": "2026-11-06T18:16:59Z"
32
+ "validated_at": "2026-11-12T19:24:12Z"
33
33
  }
@@ -2,6 +2,7 @@
2
2
  @prefix foaf: <http://xmlns.com/foaf/0.1/> .
3
3
  @prefix dc: <http://purl.org/dc/terms/> .
4
4
  @prefix : <http://knuckles.team/kg#> .
5
+ @prefix core: <https://knuckles-team.github.io/ontology/core#> .
5
6
  @prefix owl: <http://www.w3.org/2002/07/owl#> .
6
7
  @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
7
8
  @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@@ -12,12 +13,14 @@
12
13
  # per-package IRI. Backs the dockerhub-api MCP tool surface and its native ingestion (dockerhub:repository / :image / …).
13
14
 
14
15
  :Namespace a owl:Class ;
16
+ rdfs:subClassOf core:Namespace ;
15
17
  rdfs:label "Docker Hub Namespace" ;
16
18
  rdfs:comment "A Docker Hub account/organization namespace that owns image repositories (the {namespace} in namespaces/{ns}/repositories)." .
17
19
 
18
20
 
19
21
 
20
22
  :ContainerImage a owl:Class ;
23
+ rdfs:subClassOf core:ContainerImage ;
21
24
  rdfs:label "Container Image" ;
22
25
  rdfs:comment "A concrete, content-addressable OCI/Docker image (a repository tag resolving to a digest, size, and platform) — the repository-tag and registry-manifest tools." .
23
26
 
@@ -25,7 +28,7 @@
25
28
  :AccessToken a owl:Class ;
26
29
  rdfs:label "Access Token" ;
27
30
  rdfs:comment "A personal or organization access token (PAT / OAT) scoped for registry and API access (the access-token and org-access-token tools). Secret material is never ingested." ;
28
- rdfs:subClassOf :Credential .
31
+ rdfs:subClassOf core:Credential .
29
32
 
30
33
 
31
34
  # --- links (owl:ObjectProperty) ---
@@ -1,5 +1,5 @@
1
- @prefix : <http://knuckles.team/kg#> .
2
- @prefix shape: <http://knuckles.team/kg/dockerhub/shape#> .
1
+ @prefix : <https://knuckles-team.github.io/ontology/core#> .
2
+ @prefix shape: <https://knuckles-team.github.io/ontology/connector/dockerhub-shapes#> .
3
3
  @prefix sh: <http://www.w3.org/ns/shacl#> .
4
4
  @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
5
5
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dockerhub-api
3
- Version: 1.3.10
3
+ Version: 1.4.0
4
4
  Summary: Docker Hub API + MCP Server
5
5
  Author-email: Repository Maintainers <maintainers@knuckles.team>
6
6
  License: MIT
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: <3.15,>=3.12
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
- Requires-Dist: scion-sdk[mcp]>=0.9.0
15
+ Requires-Dist: scion-sdk[mcp]<0.11,>=0.10.2
16
16
  Requires-Dist: httpx>=0.28.1
17
17
  Requires-Dist: python-dotenv>=1.2.4
18
18
  Provides-Extra: test
@@ -84,8 +84,7 @@ multi-arch inspection, OCI referrers, and gated push/remove) and **Docker Scout*
84
84
  expiry, with one transparent re-mint on 401.
85
85
  - **Rate-Limit Telemetry:** `X-RateLimit-*` headers surfaced in every result;
86
86
  HTTP 429 retried with bounded `Retry-After` backoff.
87
- - **Safety by Default:** Removes and org-settings writes are gated behind
88
- `DOCKERHUB_ALLOW_DESTRUCTIVE` (default `False`); secrets are redacted from tool
87
+ - **Safety by Default:** Removes and org-settings writes are declared destructive; the SDK asks the caller to confirm (`confirm=true`) before any request. Secrets are redacted from tool
89
88
  results (plaintext tokens appear exactly once — on creation). Repository creation
90
89
  stays enabled: it is the primary release-provisioning use case.
91
90
  - **Integrated Graph Agent:** Built-in Pydantic AI agent (`dockerhub-agent`) with
@@ -279,7 +278,6 @@ _9 action-routed tool(s) (default) · 54 verbose 1:1 tool(s). Each is enabled un
279
278
  "ADMINTOOL": "True",
280
279
  "AUDITTOOL": "True",
281
280
  "AUTHTOOL": "True",
282
- "DOCKERHUB_ALLOW_DESTRUCTIVE": "False",
283
281
  "DOCKERHUB_TLS_PROFILE": "system",
284
282
  "DOCKERHUB_TOKEN": "dckr_pat_your_personal_access_token",
285
283
  "DOCKERHUB_URL": "https://hub.docker.com",
@@ -328,7 +326,6 @@ own runtime secret boundary.
328
326
  "ADMINTOOL": "True",
329
327
  "AUDITTOOL": "True",
330
328
  "AUTHTOOL": "True",
331
- "DOCKERHUB_ALLOW_DESTRUCTIVE": "False",
332
329
  "DOCKERHUB_TLS_PROFILE": "system",
333
330
  "DOCKERHUB_TOKEN": "dckr_pat_your_personal_access_token",
334
331
  "DOCKERHUB_URL": "https://hub.docker.com",
@@ -376,7 +373,6 @@ docker run -i --rm \
376
373
  -e ADMINTOOL=True \
377
374
  -e AUDITTOOL=True \
378
375
  -e AUTHTOOL=True \
379
- -e DOCKERHUB_ALLOW_DESTRUCTIVE=False \
380
376
  -e DOCKERHUB_TLS_PROFILE=system \
381
377
  -e DOCKERHUB_TOKEN=dckr_pat_your_personal_access_token \
382
378
  -e DOCKERHUB_URL=https://hub.docker.com \
@@ -423,8 +419,7 @@ the detailed transport contract.
423
419
  |---|---|---|
424
420
  | Reads (repos, tags, members, logs, SCIM) | allowed | — |
425
421
  | Repository create / immutable-tag config / invites / role updates | allowed | — |
426
- | Removes (PATs, OATs, groups, members, invites) | **blocked** | `DOCKERHUB_ALLOW_DESTRUCTIVE=True` |
427
- | Org-settings writes (`PUT /v2/orgs/{org}/settings`) | **blocked** | `DOCKERHUB_ALLOW_DESTRUCTIVE=True` |
422
+ | Removes (PATs, OATs, groups, members, invites) and org-settings writes | confirmation required | `confirm=true` per call, or operator setting `SCION_ALLOW_DESTRUCTIVE` |
428
423
 
429
424
  ---
430
425
 
@@ -488,7 +483,6 @@ network transport, connector ingestion, Arbora delegation, or trace export.
488
483
  | `DOCKER_REGISTRY_URL` | `https://registry-1.docker.io` | |
489
484
  | `DOCKER_REGISTRY_AUTH_URL` | `https://auth.docker.io/token` | token-service realm; auto-discovered from the 401 challenge |
490
485
  | `DOCKER_SCOUT_URL` | `https://api.scout.docker.com` | |
491
- | `DOCKERHUB_ALLOW_DESTRUCTIVE` | `False` | |
492
486
  | `AUTHTOOL` | `True` | |
493
487
  | `REPOSTOOL` | `True` | |
494
488
  | `ORGTOOL` | `True` | |
@@ -60,6 +60,7 @@ tests/test_api_teams.py
60
60
  tests/test_api_tokens.py
61
61
  tests/test_api_wrapper.py
62
62
  tests/test_auth.py
63
+ tests/test_destructive_declaration.py
63
64
  tests/test_dockerhub_a2a_validation.py
64
65
  tests/test_dockerhub_mcp_validation.py
65
66
  tests/test_fleet_name.py
@@ -1,4 +1,4 @@
1
- scion-sdk[mcp]>=0.9.0
1
+ scion-sdk[mcp]<0.11,>=0.10.2
2
2
  httpx>=0.28.1
3
3
  python-dotenv>=1.2.4
4
4
 
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dockerhub-api"
7
- version = "1.3.10"
7
+ version = "1.4.0"
8
8
  description = "Docker Hub API + MCP Server"
9
9
  readme = "README.md"
10
10
  classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", "Environment :: Console", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3",]
11
11
  requires-python = ">=3.12, <3.15"
12
- dependencies = [ "scion-sdk[mcp]>=0.9.0", "httpx>=0.28.1", "python-dotenv>=1.2.4",]
12
+ dependencies = [ "scion-sdk[mcp]>=0.10.2,<0.11", "httpx>=0.28.1", "python-dotenv>=1.2.4",]
13
13
  [[project.authors]]
14
14
  name = "Repository Maintainers"
15
15
  email = "maintainers@knuckles.team"
@@ -4,7 +4,6 @@ import json
4
4
 
5
5
  import pytest
6
6
 
7
- from dockerhub_api.api.api_client_base import DestructiveOperationError
8
7
 
9
8
 
10
9
  def test_get_org_settings(api):
@@ -12,13 +11,8 @@ def test_get_org_settings(api):
12
11
  assert result["data"]["restricted_images"]["enabled"] is False
13
12
 
14
13
 
15
- def test_update_org_settings_gated(api):
16
- with pytest.raises(DestructiveOperationError, match="update_org_settings"):
17
- api.update_org_settings(org="acme", restricted_images_enabled=True)
18
-
19
-
20
- def test_update_org_settings_payload(hub, api_destructive):
21
- result = api_destructive.update_org_settings(
14
+ def test_update_org_settings_payload(hub, api):
15
+ result = api.update_org_settings(
22
16
  org="acme",
23
17
  restricted_images_enabled=True,
24
18
  allow_official_images=True,
@@ -72,11 +66,9 @@ def test_update_member_role(hub, api):
72
66
  assert hub.requests[-1].method == "PUT"
73
67
 
74
68
 
75
- def test_remove_member_gated(api, api_destructive):
76
- with pytest.raises(DestructiveOperationError):
77
- api.remove_org_member(org="acme", username="dev")
69
+ def test_remove_member_gated(api):
78
70
  assert (
79
- api_destructive.remove_org_member(org="acme", username="dev")["status_code"]
71
+ api.remove_org_member(org="acme", username="dev")["status_code"]
80
72
  == 204
81
73
  )
82
74
 
@@ -86,10 +78,8 @@ def test_list_invites(api):
86
78
  assert result["data"]["results"][0]["id"] == "inv-1"
87
79
 
88
80
 
89
- def test_delete_invite_gated(api, api_destructive):
90
- with pytest.raises(DestructiveOperationError):
91
- api.delete_invite(invite_id="inv-1")
92
- assert api_destructive.delete_invite(invite_id="inv-1")["status_code"] == 204
81
+ def test_delete_invite_gated(api):
82
+ assert api.delete_invite(invite_id="inv-1")["status_code"] == 204
93
83
 
94
84
 
95
85
  def test_resend_invite(hub, api):
@@ -3,7 +3,6 @@
3
3
  import httpx
4
4
  import pytest
5
5
 
6
- from dockerhub_api.api.api_client_base import DestructiveOperationError
7
6
  from tests.conftest import (
8
7
  AMD64_DIGEST,
9
8
  CONFIG_DIGEST,
@@ -86,45 +85,33 @@ def test_check_manifest_and_blob(registry_api):
86
85
  # --------------------------- destructive gating --------------------------- #
87
86
 
88
87
 
89
- def test_delete_manifest_gated_by_default(registry_api):
90
- with pytest.raises(DestructiveOperationError, match="delete_manifest"):
91
- registry_api.delete_manifest("nginx", "latest")
92
-
93
-
94
- def test_delete_manifest_resolves_tag_then_deletes(registry, registry_api_destructive):
95
- result = registry_api_destructive.delete_manifest("nginx", "latest")
88
+ def test_delete_manifest_resolves_tag_then_deletes(registry, registry_api):
89
+ result = registry_api.delete_manifest("nginx", "latest")
96
90
  assert result["status_code"] == 202
97
91
  paths = _registry_paths(registry)
98
92
  # tag resolved to a digest, then DELETE issued against the digest
99
93
  assert f"/v2/library/nginx/manifests/{INDEX_DIGEST}" in paths
100
94
 
101
95
 
102
- def test_delete_blob_surfaces_405_cleanly(registry_api_destructive):
103
- result = registry_api_destructive.delete_blob("nginx", CONFIG_DIGEST)
96
+ def test_delete_blob_surfaces_405_cleanly(registry_api):
97
+ result = registry_api.delete_blob("nginx", CONFIG_DIGEST)
104
98
  assert result["status_code"] == 405
105
99
 
106
100
 
107
- def test_push_session_gated(registry_api):
108
- with pytest.raises(DestructiveOperationError):
109
- registry_api.start_upload("myorg/app")
110
- with pytest.raises(DestructiveOperationError):
111
- registry_api.put_manifest("myorg/app", "v1", {"schemaVersion": 2}, "media")
112
-
113
-
114
- def test_full_push_session(registry, registry_api_destructive):
115
- started = registry_api_destructive.start_upload("myorg/app")
101
+ def test_full_push_session(registry, registry_api):
102
+ started = registry_api.start_upload("myorg/app")
116
103
  location = started["headers"]["Location"]
117
104
  assert started["status_code"] == 202
118
105
 
119
- patched = registry_api_destructive.upload_chunk("myorg/app", location, b"layerbytes")
106
+ patched = registry_api.upload_chunk("myorg/app", location, b"layerbytes")
120
107
  assert patched["status_code"] == 202
121
108
 
122
- completed = registry_api_destructive.complete_upload(
109
+ completed = registry_api.complete_upload(
123
110
  "myorg/app", location, AMD64_DIGEST, final_chunk=b""
124
111
  )
125
112
  assert completed["status_code"] == 201
126
113
 
127
- put = registry_api_destructive.put_manifest(
114
+ put = registry_api.put_manifest(
128
115
  "myorg/app",
129
116
  "v1",
130
117
  {"schemaVersion": 2, "mediaType": "x", "config": {}, "layers": []},
@@ -133,8 +120,8 @@ def test_full_push_session(registry, registry_api_destructive):
133
120
  assert put["status_code"] == 201
134
121
 
135
122
 
136
- def test_mount_blob(registry_api_destructive):
137
- result = registry_api_destructive.mount_blob("myorg/app", CONFIG_DIGEST, "library/nginx")
123
+ def test_mount_blob(registry_api):
124
+ result = registry_api.mount_blob("myorg/app", CONFIG_DIGEST, "library/nginx")
138
125
  assert result["status_code"] == 201
139
126
 
140
127
 
@@ -4,7 +4,6 @@ import json
4
4
 
5
5
  import pytest
6
6
 
7
- from dockerhub_api.api.api_client_base import DestructiveOperationError
8
7
 
9
8
 
10
9
  def test_list_groups(hub, api):
@@ -44,25 +43,21 @@ def test_update_group_requires_change(api):
44
43
  api.update_group(org="acme", group_name="platform")
45
44
 
46
45
 
47
- def test_delete_group_gated(api, api_destructive):
48
- with pytest.raises(DestructiveOperationError):
49
- api.delete_group(org="acme", group_name="platform")
46
+ def test_delete_group_gated(api):
50
47
  assert (
51
- api_destructive.delete_group(org="acme", group_name="platform")["status_code"]
48
+ api.delete_group(org="acme", group_name="platform")["status_code"]
52
49
  == 204
53
50
  )
54
51
 
55
52
 
56
- def test_group_members_roundtrip(hub, api, api_destructive):
53
+ def test_group_members_roundtrip(hub, api):
57
54
  members = api.get_group_members(org="acme", group_name="platform")
58
55
  assert members["data"]["results"][0]["username"] == "tester"
59
56
 
60
57
  added = api.add_group_member(org="acme", group_name="platform", member="dev")
61
58
  assert added["data"]["username"] == "dev"
62
59
 
63
- with pytest.raises(DestructiveOperationError):
64
- api.remove_group_member(org="acme", group_name="platform", username="dev")
65
- removed = api_destructive.remove_group_member(
60
+ removed = api.remove_group_member(
66
61
  org="acme", group_name="platform", username="dev"
67
62
  )
68
63
  assert removed["status_code"] == 204
@@ -4,7 +4,6 @@ import json
4
4
 
5
5
  import pytest
6
6
 
7
- from dockerhub_api.api.api_client_base import DestructiveOperationError
8
7
 
9
8
 
10
9
  def test_list_pats_pagination(hub, api):
@@ -40,13 +39,8 @@ def test_update_pat_requires_a_change(api):
40
39
  api.update_access_token(uuid="pat-1")
41
40
 
42
41
 
43
- def test_delete_pat_gated_by_default(api):
44
- with pytest.raises(DestructiveOperationError, match="delete_access_token"):
45
- api.delete_access_token(uuid="pat-1")
46
-
47
-
48
- def test_delete_pat_allowed_when_enabled(hub, api_destructive):
49
- result = api_destructive.delete_access_token(uuid="pat-1")
42
+ def test_delete_pat_allowed_when_enabled(hub, api):
43
+ result = api.delete_access_token(uuid="pat-1")
50
44
  assert result["status_code"] == 204
51
45
  assert hub.requests[-1].method == "DELETE"
52
46
 
@@ -91,11 +85,9 @@ def test_get_update_oat(hub, api):
91
85
  assert result["data"]["is_active"] is False
92
86
 
93
87
 
94
- def test_delete_oat_gated(api, api_destructive, hub):
95
- with pytest.raises(DestructiveOperationError):
96
- api.delete_org_access_token(org="acme", token_id="oat-1") # nosec B105 B106 — fake test credential
88
+ def test_delete_oat_gated(api, hub):
97
89
  assert (
98
- api_destructive.delete_org_access_token(org="acme", token_id="oat-1")[ # nosec B105 B106 — fake test credential
90
+ api.delete_org_access_token(org="acme", token_id="oat-1")[ # nosec B105 B106 — fake test credential
99
91
  "status_code"
100
92
  ]
101
93
  == 204
@@ -4,7 +4,6 @@ and destructive gating at the facade level."""
4
4
 
5
5
  import pytest
6
6
 
7
- from dockerhub_api.api.api_client_base import DestructiveOperationError
8
7
  from dockerhub_api.api_client import Api
9
8
 
10
9
 
@@ -46,11 +45,6 @@ def test_facade_composes_all_domain_mixins():
46
45
  assert callable(getattr(Api, method, None)), f"missing {method}"
47
46
 
48
47
 
49
- def test_destructive_call_blocked_by_default(api):
50
- with pytest.raises(DestructiveOperationError):
51
- api.delete_access_token(uuid="uuid-1")
52
-
53
-
54
- def test_destructive_call_allowed_when_enabled(api_destructive):
55
- result = api_destructive.delete_access_token(uuid="uuid-1")
48
+ def test_destructive_call_allowed_when_enabled(api):
49
+ result = api.delete_access_token(uuid="uuid-1")
56
50
  assert result["status_code"] in (200, 202, 204)
@@ -167,12 +167,6 @@ def test_get_client_uses_token_manager(monkeypatch):
167
167
  assert client._token_manager.identifier == "tester"
168
168
 
169
169
 
170
- def test_get_client_destructive_env(monkeypatch):
171
- monkeypatch.setenv("DOCKERHUB_ALLOW_DESTRUCTIVE", "True")
172
- client = get_client()
173
- assert client.allow_destructive is True
174
-
175
-
176
170
  def test_get_client_anonymous(monkeypatch):
177
171
  monkeypatch.delenv("DOCKERHUB_USERNAME", raising=False)
178
172
  monkeypatch.delenv("DOCKERHUB_TOKEN", raising=False)
@@ -0,0 +1,98 @@
1
+ """CONN-DOCKERHUB-API-CONNECTOR-CONTENT-R003: destructive operations use the SDK's one declaration and guard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from unittest.mock import patch
7
+
8
+ import httpx
9
+ import pytest
10
+ from fastmcp import Client
11
+ from scion_sdk.connector import build_server
12
+
13
+ pytestmark = pytest.mark.spec("CONN-DOCKERHUB-API-CONNECTOR-CONTENT-R003")
14
+
15
+ DESTRUCTIVE_TOOLS = {
16
+ "dockerhub_delete_access_token": {"uuid": "u1"},
17
+ "dockerhub_delete_group": {"org": "acme", "group_name": "g"},
18
+ "dockerhub_delete_invite": {"invite_id": "i1"},
19
+ "dockerhub_delete_org_access_token": {"org": "acme", "token_id": "t1"},
20
+ "dockerhub_remove_group_member": {
21
+ "org": "acme",
22
+ "group_name": "g",
23
+ "username": "u",
24
+ },
25
+ "dockerhub_remove_org_member": {"org": "acme", "username": "u"},
26
+ "dockerhub_update_org_settings": {"org": "acme", "restricted_images_enabled": True},
27
+ "dockerhub_registry_delete_blob": {
28
+ "repo": "acme/app",
29
+ "digest": "sha256:" + "a" * 64,
30
+ },
31
+ "dockerhub_registry_delete_manifest": {"repo": "acme/app", "reference": "latest"},
32
+ "dockerhub_registry_start_upload": {"repo": "acme/app"},
33
+ }
34
+
35
+
36
+ def _server(requests: list[httpx.Request], monkeypatch):
37
+ real = httpx.Client
38
+
39
+ def record(request: httpx.Request) -> httpx.Response:
40
+ requests.append(request)
41
+ return httpx.Response(200, json={})
42
+
43
+ def client(*args, **kwargs):
44
+ kwargs["transport"] = httpx.MockTransport(record)
45
+ return real(*args, **kwargs)
46
+
47
+ monkeypatch.setattr("dockerhub_api.api.api_client_base.httpx.Client", client)
48
+ monkeypatch.setenv("DOCKERHUB_JWT", "jwt")
49
+ monkeypatch.delenv("SCION_ALLOW_DESTRUCTIVE", raising=False)
50
+ with patch("sys.argv", ["dockerhub-mcp"]):
51
+ return build_server("dockerhub_api", command_args=[])[1]
52
+
53
+
54
+ def test_declared_operations_are_destructive_tools_that_ask_first(monkeypatch):
55
+ mcp = _server([], monkeypatch)
56
+
57
+ async def _run():
58
+ async with Client(mcp) as client:
59
+ tools = {t.name: t for t in await client.list_tools()}
60
+ for name in DESTRUCTIVE_TOOLS:
61
+ assert tools[name].annotations.destructive_hint is True, name
62
+ for name, tool in tools.items():
63
+ properties = tool.input_schema["properties"]
64
+ assert "allow_destructive" not in properties, name
65
+ if tool.annotations and tool.annotations.destructive_hint:
66
+ assert "confirm" in properties, name
67
+
68
+ asyncio.run(_run())
69
+
70
+
71
+ @pytest.mark.parametrize("name", sorted(DESTRUCTIVE_TOOLS))
72
+ def test_unconfirmed_call_sends_no_request(name, monkeypatch):
73
+ requests: list[httpx.Request] = []
74
+ mcp = _server(requests, monkeypatch)
75
+
76
+ async def _run():
77
+ async with Client(mcp) as client:
78
+ refused = await client.call_tool(
79
+ name, DESTRUCTIVE_TOOLS[name], raise_on_error=False
80
+ )
81
+ assert refused.structured_content["cancelled"] is True
82
+
83
+ asyncio.run(_run())
84
+ assert requests == []
85
+
86
+
87
+ def test_confirmed_call_sends_one_request(monkeypatch):
88
+ requests: list[httpx.Request] = []
89
+ mcp = _server(requests, monkeypatch)
90
+
91
+ async def _run():
92
+ async with Client(mcp) as client:
93
+ await client.call_tool(
94
+ "dockerhub_delete_access_token", {"uuid": "u1", "confirm": True}
95
+ )
96
+
97
+ asyncio.run(_run())
98
+ assert [r.method for r in requests] == ["DELETE"]
File without changes
File without changes