deepctl-cmd-api 0.0.2__tar.gz → 0.0.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepctl-cmd-api
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: API command for deepctl
5
5
  Author-email: Deepgram <devrel@deepgram.com>
6
6
  Maintainer-email: Deepgram <devrel@deepgram.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deepctl-cmd-api"
7
- version = "0.0.2" # x-release-please-version
7
+ version = "0.0.3" # x-release-please-version
8
8
  description = "API command for deepctl"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,18 +5,41 @@ import shutil
5
5
  import subprocess
6
6
  import sys
7
7
  from typing import Any
8
+ from urllib.parse import urlsplit, urlunsplit
8
9
 
9
10
  import httpx
10
- from deepctl_core import AuthManager, BaseCommand, Config, DeepgramClient
11
+ from deepctl_core import (
12
+ AuthManager,
13
+ BaseCommand,
14
+ Config,
15
+ DeepgramClient,
16
+ is_agentic,
17
+ is_ai_agent,
18
+ )
19
+ from deepctl_core import messages as core_msg
20
+ from deepctl_core.hosts import (
21
+ DEFAULT_BASE_URL,
22
+ UrlOrigin,
23
+ is_allowlisted,
24
+ is_loopback,
25
+ is_regional,
26
+ parse_origin,
27
+ same_origin,
28
+ )
29
+ from deepctl_core.output import (
30
+ print_error,
31
+ print_hint,
32
+ print_security_warning,
33
+ )
11
34
  from rich.console import Console
35
+ from rich.markup import escape
12
36
  from rich.panel import Panel
13
37
 
14
- from .models import ApiResult
38
+ from . import messages as msg
39
+ from .models import ApiErrorResult, ApiResult
15
40
 
16
41
  console = Console()
17
42
 
18
- DEFAULT_BASE_URL = "https://api.deepgram.com"
19
-
20
43
 
21
44
  class ApiCommand(BaseCommand):
22
45
  """Make authenticated requests to Deepgram APIs."""
@@ -91,25 +114,170 @@ class ApiCommand(BaseCommand):
91
114
  "is_flag": True,
92
115
  "is_option": True,
93
116
  },
117
+ {
118
+ "names": ["--allow-foreign-host"],
119
+ "help": (
120
+ "Send your API key to this non-Deepgram host (must equal "
121
+ "the request URL's host)"
122
+ ),
123
+ "type": str,
124
+ "is_option": True,
125
+ },
94
126
  ]
95
127
 
96
- def _resolve_url(self, endpoint: str) -> str:
97
- """Resolve endpoint to full URL."""
128
+ def _resolve_url(self, endpoint: str, base_url: str = DEFAULT_BASE_URL) -> str:
129
+ """Resolve endpoint to full URL, relative to the profile's base URL."""
98
130
  if endpoint.startswith("http://") or endpoint.startswith("https://"):
99
131
  return endpoint
100
- base = DEFAULT_BASE_URL.rstrip("/")
132
+ base = base_url.rstrip("/")
101
133
  path = endpoint if endpoint.startswith("/") else f"/{endpoint}"
102
134
  return f"{base}{path}"
103
135
 
136
+ @staticmethod
137
+ def _display_url(url: str) -> str:
138
+ """The URL without userinfo, query or fragment."""
139
+ try:
140
+ parts = urlsplit(url)
141
+ netloc = parts.hostname or ""
142
+ if ":" in netloc:
143
+ netloc = f"[{netloc}]"
144
+ if parts.port is not None:
145
+ netloc = f"{netloc}:{parts.port}"
146
+ return urlunsplit((parts.scheme, netloc, parts.path, "", ""))
147
+ except ValueError:
148
+ return ""
149
+
150
+ def pre_auth_check(self, config: Config, **kwargs: Any) -> Any | None:
151
+ """Refuse a disallowed --allow-foreign-host before any request."""
152
+ endpoint = kwargs.get("endpoint") or ""
153
+ allow_host = kwargs.get("allow_foreign_host")
154
+ if not allow_host or not endpoint.startswith(("http://", "https://")):
155
+ return None
156
+ method = (kwargs.get("method") or "GET").upper()
157
+ base_url = config.get_profile().base_url or DEFAULT_BASE_URL
158
+ _, refusal = self._authorize(
159
+ endpoint, base_url, config, allow_host, method, warn=False
160
+ )
161
+ return refusal
162
+
163
+ def _authorize(
164
+ self,
165
+ url: str,
166
+ base_url: str,
167
+ config: Config,
168
+ allow_host: str | None,
169
+ method: str,
170
+ warn: bool = True,
171
+ ) -> tuple[bool, ApiErrorResult | None]:
172
+ """Decide whether the request to ``url`` may carry the API key.
173
+
174
+ Returns ``(attach, error)``; with an error, nothing is sent.
175
+ """
176
+ target = parse_origin(url)
177
+ configured = parse_origin(base_url)
178
+ allowed = config.allowed_hosts()
179
+ if target is None:
180
+ return False, None
181
+
182
+ trusted = (
183
+ (configured is not None and same_origin(target, configured))
184
+ or is_regional(target)
185
+ or is_allowlisted(target, allowed)
186
+ )
187
+ host = core_msg.display(target.host)
188
+
189
+ if not trusted:
190
+ if not allow_host:
191
+ if not warn:
192
+ return False, None
193
+ print_security_warning(escape(msg.W_API_NOAUTH.format(host=host)))
194
+ # The hint names the override flag: never in agent mode.
195
+ if not is_agentic() and not is_ai_agent():
196
+ print_hint(escape(msg.W_API_NOAUTH_HINT.format(host=host)))
197
+ return False, None
198
+ # Refuse the command-line override under a hard AI-agent signal
199
+ # (never CI or TTY state); api.allowed_hosts is the real control.
200
+ if is_ai_agent():
201
+ return False, self._refuse(
202
+ msg.E_API_AGENT.format(host=host),
203
+ msg.E_API_AGENT_HINT.format(host=host),
204
+ core_msg.CODE_FOREIGN_HOST,
205
+ method,
206
+ url,
207
+ )
208
+ if not self._flag_matches(allow_host, target):
209
+ return False, self._refuse(
210
+ msg.E_API_MISMATCH.format(
211
+ given=core_msg.display(allow_host), host=host
212
+ ),
213
+ msg.E_API_MISMATCH_HINT.format(host=host),
214
+ core_msg.CODE_FOREIGN_HOST,
215
+ method,
216
+ url,
217
+ )
218
+
219
+ # Plain http only to a loopback host the user configured or
220
+ # allowlisted; the command-line override is https only.
221
+ if not target.secure and (not trusted or not is_loopback(target.host)):
222
+ hint = msg.E_API_INSECURE_HINT
223
+ return False, self._refuse(
224
+ msg.E_API_INSECURE.format(origin=core_msg.display(target.origin)),
225
+ hint,
226
+ core_msg.CODE_INSECURE_BASE_URL,
227
+ method,
228
+ url,
229
+ )
230
+ return True, None
231
+
232
+ @staticmethod
233
+ def _flag_matches(given: str, target: UrlOrigin) -> bool:
234
+ """``--allow-foreign-host`` names exactly the request host."""
235
+ value = given.strip().lower()
236
+ parsed = parse_origin(f"https://{value}") if value else None
237
+ if parsed is None or parsed.host != target.host:
238
+ return False
239
+ return parsed.port is None or parsed.port == target.effective_port
240
+
241
+ def _refuse(
242
+ self, error: str, hint: str, code: str, method: str, url: str
243
+ ) -> ApiErrorResult:
244
+ print_error(escape(error))
245
+ print_hint(escape(hint))
246
+ return ApiErrorResult(
247
+ status="error",
248
+ message=error,
249
+ method=method,
250
+ url=self._display_url(url),
251
+ code=code,
252
+ hint=hint,
253
+ )
254
+
255
+ def _warn_redirect(
256
+ self, response: httpx.Response, target: UrlOrigin | None
257
+ ) -> None:
258
+ """Note a redirect that wasn't followed."""
259
+ location = response.headers.get("location")
260
+ if not location or target is None:
261
+ return
262
+ dest = parse_origin(str(response.url.join(location)))
263
+ print_security_warning(
264
+ escape(
265
+ msg.W_API_REDIRECT.format(
266
+ origin=core_msg.display(target.origin),
267
+ location_origin=core_msg.display(dest.origin if dest else "?"),
268
+ )
269
+ )
270
+ )
271
+
104
272
  def _build_headers(
105
273
  self,
106
- api_key: str,
274
+ api_key: str | None,
107
275
  custom_headers: tuple[str, ...] | list[str] | None,
108
276
  ) -> dict[str, str]:
109
- """Build request headers with auth and custom headers."""
110
- headers: dict[str, str] = {
111
- "Authorization": f"Token {api_key}",
112
- }
277
+ """Build request headers with auth (when given) and custom headers."""
278
+ headers: dict[str, str] = {}
279
+ if api_key:
280
+ headers["Authorization"] = f"Token {api_key}"
113
281
  if custom_headers:
114
282
  for header in custom_headers:
115
283
  if ":" in header:
@@ -197,9 +365,16 @@ class ApiCommand(BaseCommand):
197
365
  custom_headers = kwargs.get("header") or ()
198
366
  jq_filter = kwargs.get("jq")
199
367
  raw_output = kwargs.get("raw", False)
368
+ allow_host = kwargs.get("allow_foreign_host")
369
+
370
+ # Resolve URL against the profile's base URL. check_base_url raises
371
+ # (already reported) when that host isn't trusted with the key.
372
+ base_url = auth_manager.check_base_url()
373
+ url = self._resolve_url(endpoint, base_url)
200
374
 
201
- # Resolve URL
202
- url = self._resolve_url(endpoint)
375
+ attach, refusal = self._authorize(url, base_url, config, allow_host, method)
376
+ if refusal is not None:
377
+ return refusal
203
378
 
204
379
  # Get API key
205
380
  api_key = auth_manager.get_api_key()
@@ -212,8 +387,8 @@ class ApiCommand(BaseCommand):
212
387
  url=url,
213
388
  )
214
389
 
215
- # Build headers
216
- headers = self._build_headers(api_key, custom_headers)
390
+ # Build headers (the key only goes to a host that may receive it)
391
+ headers = self._build_headers(api_key if attach else None, custom_headers)
217
392
 
218
393
  # Build body
219
394
  try:
@@ -242,7 +417,8 @@ class ApiCommand(BaseCommand):
242
417
 
243
418
  # Make request
244
419
  try:
245
- with httpx.Client(timeout=30.0) as http_client:
420
+ # Never follow redirects: a 3xx could point the key elsewhere.
421
+ with httpx.Client(timeout=30.0, follow_redirects=False) as http_client:
246
422
  response = http_client.request(**request_kwargs)
247
423
  except httpx.RequestError as e:
248
424
  console.print(f"[red]Request failed: {e}[/red]")
@@ -254,6 +430,8 @@ class ApiCommand(BaseCommand):
254
430
  )
255
431
 
256
432
  elapsed_ms = response.elapsed.total_seconds() * 1000
433
+ if response.is_redirect:
434
+ self._warn_redirect(response, parse_origin(url))
257
435
 
258
436
  # Handle response
259
437
  response_text = response.text
@@ -0,0 +1,31 @@
1
+ """User-facing strings for `dg api`, keyed by message ID.
2
+
3
+ Placeholders are filled with ``deepctl_core.messages.display`` values; a
4
+ composed line is markup-escaped before it is printed.
5
+ """
6
+
7
+ from deepctl_core import messages as _core_msg
8
+
9
+ W_API_NOAUTH = (
10
+ "Sending the request to {host} without your API key: it is not a Deepgram API host."
11
+ )
12
+ W_API_NOAUTH_HINT = "To include your key, pass --allow-foreign-host={host}"
13
+ E_API_MISMATCH = (
14
+ "--allow-foreign-host={given} does not match the request host {host}. "
15
+ "Nothing was sent."
16
+ )
17
+ E_API_MISMATCH_HINT = "If you trust {host}, pass --allow-foreign-host={host}"
18
+ E_API_AGENT = (
19
+ "Not sending your API key to {host}: this session looks like an AI agent, "
20
+ "so command-line overrides aren't accepted. Nothing was sent."
21
+ )
22
+ E_API_AGENT_HINT = _core_msg.E_FH_AGENT_HINT
23
+ E_API_INSECURE = (
24
+ "Not sending your API key to {origin}: keys are sent over plain http only "
25
+ "to a loopback host you configured or approved. Nothing was sent."
26
+ )
27
+ E_API_INSECURE_HINT = "Use an https:// URL."
28
+ W_API_REDIRECT = (
29
+ "{origin} answered with a redirect to {location_origin}. dg api doesn't "
30
+ "follow redirects."
31
+ )
@@ -13,3 +13,13 @@ class ApiResult(BaseResult):
13
13
  status_code: int = 0
14
14
  response_body: Any = None
15
15
  elapsed_ms: float | None = None
16
+
17
+
18
+ class ApiErrorResult(ApiResult):
19
+ """`dg api` error that also carries a machine-readable code and a hint.
20
+
21
+ A subclass so every other `dg api` payload keeps its existing keys.
22
+ """
23
+
24
+ code: str
25
+ hint: str
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepctl-cmd-api
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: API command for deepctl
5
5
  Author-email: Deepgram <devrel@deepgram.com>
6
6
  Maintainer-email: Deepgram <devrel@deepgram.com>
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  src/deepctl_cmd_api/__init__.py
4
4
  src/deepctl_cmd_api/command.py
5
+ src/deepctl_cmd_api/messages.py
5
6
  src/deepctl_cmd_api/models.py
6
7
  src/deepctl_cmd_api.egg-info/PKG-INFO
7
8
  src/deepctl_cmd_api.egg-info/SOURCES.txt