haru-cli 0.1.3__tar.gz → 0.1.4__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 (94) hide show
  1. {haru_cli-0.1.3 → haru_cli-0.1.4}/CHANGELOG.md +16 -0
  2. {haru_cli-0.1.3 → haru_cli-0.1.4}/PKG-INFO +1 -1
  3. {haru_cli-0.1.3 → haru_cli-0.1.4}/pyproject.toml +1 -1
  4. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/auth/sso.py +126 -6
  5. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_sso.py +120 -1
  6. {haru_cli-0.1.3 → haru_cli-0.1.4}/uv.lock +1 -1
  7. {haru_cli-0.1.3 → haru_cli-0.1.4}/.github/workflows/ci.yml +0 -0
  8. {haru_cli-0.1.3 → haru_cli-0.1.4}/.github/workflows/release.yml +0 -0
  9. {haru_cli-0.1.3 → haru_cli-0.1.4}/.gitignore +0 -0
  10. {haru_cli-0.1.3 → haru_cli-0.1.4}/.kiro/steering/coding-standards.md +0 -0
  11. {haru_cli-0.1.3 → haru_cli-0.1.4}/.kiro/steering/git-workflow.md +0 -0
  12. {haru_cli-0.1.3 → haru_cli-0.1.4}/.kiro/steering/product.md +0 -0
  13. {haru_cli-0.1.3 → haru_cli-0.1.4}/.kiro/steering/security.md +0 -0
  14. {haru_cli-0.1.3 → haru_cli-0.1.4}/.kiro/steering/structure.md +0 -0
  15. {haru_cli-0.1.3 → haru_cli-0.1.4}/.kiro/steering/tech.md +0 -0
  16. {haru_cli-0.1.3 → haru_cli-0.1.4}/.kiro/steering/testing.md +0 -0
  17. {haru_cli-0.1.3 → haru_cli-0.1.4}/.pre-commit-config.yaml +0 -0
  18. {haru_cli-0.1.3 → haru_cli-0.1.4}/.python-version +0 -0
  19. {haru_cli-0.1.3 → haru_cli-0.1.4}/CLAUDE.md +0 -0
  20. {haru_cli-0.1.3 → haru_cli-0.1.4}/LICENSE +0 -0
  21. {haru_cli-0.1.3 → haru_cli-0.1.4}/README.md +0 -0
  22. {haru_cli-0.1.3 → haru_cli-0.1.4}/config/agents.yaml +0 -0
  23. {haru_cli-0.1.3 → haru_cli-0.1.4}/config/guardrails.yaml +0 -0
  24. {haru_cli-0.1.3 → haru_cli-0.1.4}/config/haru.yaml +0 -0
  25. {haru_cli-0.1.3 → haru_cli-0.1.4}/config/logging.yaml +0 -0
  26. {haru_cli-0.1.3 → haru_cli-0.1.4}/config/mcp.yaml +0 -0
  27. {haru_cli-0.1.3 → haru_cli-0.1.4}/config/models.yaml +0 -0
  28. {haru_cli-0.1.3 → haru_cli-0.1.4}/config/prompts/researcher.md +0 -0
  29. {haru_cli-0.1.3 → haru_cli-0.1.4}/config/prompts/supervisor.md +0 -0
  30. {haru_cli-0.1.3 → haru_cli-0.1.4}/config/prompts/writer.md +0 -0
  31. {haru_cli-0.1.3 → haru_cli-0.1.4}/docs/configuration.md +0 -0
  32. {haru_cli-0.1.3 → haru_cli-0.1.4}/renovate.json +0 -0
  33. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/__init__.py +0 -0
  34. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/__main__.py +0 -0
  35. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/agents/__init__.py +0 -0
  36. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/agents/factory.py +0 -0
  37. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/agents/orchestration.py +0 -0
  38. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/auth/__init__.py +0 -0
  39. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/auth/cache.py +0 -0
  40. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/auth/identity.py +0 -0
  41. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/auth/pkce.py +0 -0
  42. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/auth/session.py +0 -0
  43. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/cli.py +0 -0
  44. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/__init__.py +0 -0
  45. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/agents.py +0 -0
  46. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/chat.py +0 -0
  47. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/config.py +0 -0
  48. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/login.py +0 -0
  49. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/run.py +0 -0
  50. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/session.py +0 -0
  51. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/streaming.py +0 -0
  52. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/__init__.py +0 -0
  53. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/loader.py +0 -0
  54. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/schema.py +0 -0
  55. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/__init__.py +0 -0
  56. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/agents.yaml +0 -0
  57. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/haru.yaml +0 -0
  58. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/logging.yaml +0 -0
  59. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/mcp.yaml +0 -0
  60. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/models.yaml +0 -0
  61. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/prompts/researcher.md +0 -0
  62. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/prompts/supervisor.md +0 -0
  63. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/prompts/writer.md +0 -0
  64. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/errors.py +0 -0
  65. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/models/__init__.py +0 -0
  66. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/models/bedrock.py +0 -0
  67. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/observability/__init__.py +0 -0
  68. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/observability/guardrails.py +0 -0
  69. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/observability/telemetry.py +0 -0
  70. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/sessions/__init__.py +0 -0
  71. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/sessions/manager.py +0 -0
  72. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/steering/__init__.py +0 -0
  73. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/steering/prompts.py +0 -0
  74. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/tools/__init__.py +0 -0
  75. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/tools/mcp.py +0 -0
  76. {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/tools/registry.py +0 -0
  77. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/conftest.py +0 -0
  78. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/integration/test_packaging.py +0 -0
  79. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_agent_factory.py +0 -0
  80. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_bedrock.py +0 -0
  81. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_cache.py +0 -0
  82. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_cli.py +0 -0
  83. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_config_command.py +0 -0
  84. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_config_loader.py +0 -0
  85. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_guardrails.py +0 -0
  86. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_identity.py +0 -0
  87. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_main.py +0 -0
  88. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_mcp.py +0 -0
  89. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_orchestration.py +0 -0
  90. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_pkce.py +0 -0
  91. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_prompts.py +0 -0
  92. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_session.py +0 -0
  93. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_sessions.py +0 -0
  94. {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_telemetry.py +0 -0
@@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
11
 
12
+ - The login callback now serves a proper local result page (dark themed,
13
+ approval/denial card): "Request approved" on success, "Request denied"
14
+ with the reason on error, missing parameters, or a state mismatch —
15
+ rendered server-side with no JavaScript.
16
+
17
+ ### Security
18
+
19
+ - Login result pages ship strict browser security headers: CSP
20
+ (`default-src 'none'`, inline styles only, `frame-ancestors 'none'`),
21
+ `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
22
+ `Referrer-Policy: no-referrer`, and `Cache-Control: no-store`; error
23
+ descriptions are HTML-escaped, and the `state` parameter is now also
24
+ validated in the callback handler before the browser is answered.
25
+
26
+ ### Added
27
+
12
28
  - Browser-only login: `haru login` now discovers your accessible AWS
13
29
  accounts and permission-set roles from Identity Center after sign-in
14
30
  (auto-selected when unambiguous, prompted otherwise) and remembers the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haru-cli
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Secure, scriptable CLI for Amazon Bedrock Claude models via the AWS Strands Agents SDK.
5
5
  Author: haru-cli maintainers
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "haru-cli"
3
- version = "0.1.3"
3
+ version = "0.1.4"
4
4
  description = "Secure, scriptable CLI for Amazon Bedrock Claude models via the AWS Strands Agents SDK."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -6,6 +6,7 @@ endpoint, capture the code on a 127.0.0.1 loopback listener, and exchange it
6
6
  with ``CreateToken``. Only loopback redirect URIs are accepted.
7
7
  """
8
8
 
9
+ import html
9
10
  import secrets
10
11
  import time
11
12
  import webbrowser
@@ -13,6 +14,7 @@ from collections.abc import Callable
13
14
  from dataclasses import dataclass
14
15
  from datetime import UTC, datetime, timedelta
15
16
  from http.server import BaseHTTPRequestHandler, HTTPServer
17
+ from string import Template
16
18
  from typing import Any, cast
17
19
  from urllib.parse import parse_qs, urlencode, urlsplit
18
20
 
@@ -26,9 +28,74 @@ from haru.errors import AuthError
26
28
  _LOOPBACK_HOST = "127.0.0.1"
27
29
  _CALLBACK_PATH = "/oauth/callback"
28
30
  _AUTHORIZE_SCOPES = "sso:account:access"
29
- _CALLBACK_RESPONSE = (
30
- b"<html><body><p>Sign-in complete. You can close this window and return to haru.</p>"
31
- b"</body></html>"
31
+
32
+ # Self-contained result page: inline CSS and inline SVG only no scripts and
33
+ # no external resources. The outcome is rendered server-side (unlike client
34
+ # scripting, this works under the strict CSP below) and the strict CSP
35
+ # enforces the no-script, no-resource policy in the browser.
36
+ _PAGE_TEMPLATE = Template("""<!DOCTYPE html>
37
+ <html lang="en">
38
+ <head>
39
+ <meta charset="utf-8">
40
+ <meta name="viewport" content="width=device-width, initial-scale=1">
41
+ <title>$title</title>
42
+ <style>
43
+ html { height: 100%; }
44
+ body {
45
+ box-sizing: border-box; min-height: 100%; margin: 0; padding: 15px 30px;
46
+ display: flex; flex-direction: column; justify-content: center;
47
+ align-items: center; min-width: 400px;
48
+ font-family: Verdana, Geneva, Tahoma, sans-serif; font-size: 0.9rem;
49
+ background-color: #000000; color: #e0e0e0;
50
+ }
51
+ .logo { font-size: 2rem; font-weight: bold; letter-spacing: 0.04em;
52
+ margin-bottom: 50px; color: #ffffff; }
53
+ .logo span { color: #7b5cfa; }
54
+ .request {
55
+ border: 1px solid; border-radius: 6px; padding: 20px 24px;
56
+ display: flex; flex-direction: row; align-items: flex-start; gap: 10px;
57
+ }
58
+ .request h4 { margin: 0 0 4px 0; }
59
+ .request p { margin: 0; opacity: 0.85; }
60
+ .approval { background-color: rgba(46, 204, 113, 0.08); border-color: #2ecc71; }
61
+ .approval h4, .approval p { color: #2ecc71; }
62
+ .denial { background-color: rgba(231, 76, 60, 0.08); border-color: #e74c3c; }
63
+ .denial h4, .denial p { color: #e74c3c; }
64
+ .icon { flex-shrink: 0; margin-top: 2px; margin-right: 5px; }
65
+ .hint { color: #a0a0b8; text-align: center; margin-top: 24px; }
66
+ </style>
67
+ </head>
68
+ <body>
69
+ <div class="logo">haru <span>CLI</span></div>
70
+ <div>
71
+ <div class="request $variant">
72
+ <svg class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none"
73
+ stroke="$stroke" stroke-width="2" stroke-linecap="round"
74
+ stroke-linejoin="round">$icon</svg>
75
+ <div>
76
+ <h4>$heading</h4>
77
+ <p>$message</p>
78
+ </div>
79
+ </div>
80
+ <p class="hint">$footer</p>
81
+ </div>
82
+ </body>
83
+ </html>""")
84
+
85
+ _CHECK_ICON = '<polyline points="20 6 9 17 4 12"></polyline>'
86
+ _CROSS_ICON = (
87
+ '<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>'
88
+ )
89
+
90
+ _SECURITY_HEADERS = (
91
+ (
92
+ "Content-Security-Policy",
93
+ "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'",
94
+ ),
95
+ ("X-Content-Type-Options", "nosniff"),
96
+ ("X-Frame-Options", "DENY"),
97
+ ("Referrer-Policy", "no-referrer"),
98
+ ("Cache-Control", "no-store"),
32
99
  )
33
100
 
34
101
 
@@ -142,6 +209,7 @@ def run_login(
142
209
  registration = register_client(oidc, sso.client_name, sso.start_url, redirect_uri)
143
210
  verifier, challenge = generate_pkce_pair()
144
211
  state = secrets.token_urlsafe(32)
212
+ server.expected_state = state
145
213
  open_url(
146
214
  build_authorize_url(
147
215
  sso.sso_region, registration.client_id, redirect_uri, state, challenge
@@ -159,6 +227,7 @@ class _CallbackServer(HTTPServer):
159
227
  """Loopback HTTP server that records the OAuth callback query parameters."""
160
228
 
161
229
  callback_params: dict[str, list[str]] | None = None
230
+ expected_state: str | None = None
162
231
 
163
232
 
164
233
  class _CallbackHandler(BaseHTTPRequestHandler):
@@ -166,20 +235,71 @@ class _CallbackHandler(BaseHTTPRequestHandler):
166
235
 
167
236
  def do_GET(self) -> None:
168
237
  parsed = urlsplit(self.path)
238
+ if parsed.path == "/favicon.ico":
239
+ self.send_response(204)
240
+ self.end_headers()
241
+ return
169
242
  if parsed.path != _CALLBACK_PATH:
170
243
  self.send_error(404)
171
244
  return
172
245
  server = cast(_CallbackServer, self.server)
173
- server.callback_params = parse_qs(parsed.query)
246
+ params = parse_qs(parsed.query)
247
+ server.callback_params = params
248
+ self._respond(_render_outcome(params, server.expected_state))
249
+
250
+ def _respond(self, page: bytes) -> None:
174
251
  self.send_response(200)
175
- self.send_header("Content-Type", "text/html")
252
+ self.send_header("Content-Type", "text/html; charset=utf-8")
253
+ self.send_header("Content-Length", str(len(page)))
254
+ for header, value in _SECURITY_HEADERS:
255
+ self.send_header(header, value)
176
256
  self.end_headers()
177
- self.wfile.write(_CALLBACK_RESPONSE)
257
+ self.wfile.write(page)
178
258
 
179
259
  def log_message(self, format: str, *args: object) -> None:
180
260
  """Silence default request logging; callback URLs must never be logged."""
181
261
 
182
262
 
263
+ def _render_outcome(params: dict[str, list[str]], expected_state: str | None) -> bytes:
264
+ """Render the approval or denial page for the callback the browser sent."""
265
+ error = params.get("error", [""])[0]
266
+ if error:
267
+ description = params.get("error_description", [""])[0]
268
+ reason = f"{error}: {description}" if description else error
269
+ return _failure_page(html.escape(reason))
270
+ codes = params.get("code")
271
+ states = params.get("state")
272
+ if not codes or not states:
273
+ return _failure_page("The sign-in response was missing required parameters")
274
+ if expected_state is not None and not secrets.compare_digest(states[0], expected_state):
275
+ return _failure_page("The sign-in response failed a security check (state mismatch)")
276
+ return _success_page()
277
+
278
+
279
+ def _success_page() -> bytes:
280
+ return _PAGE_TEMPLATE.substitute(
281
+ title="haru CLI authentication",
282
+ variant="approval",
283
+ stroke="#2ecc71",
284
+ icon=_CHECK_ICON,
285
+ heading="Request approved",
286
+ message="haru CLI has been given the requested permissions",
287
+ footer="You can close this window and start using haru CLI",
288
+ ).encode("utf-8")
289
+
290
+
291
+ def _failure_page(reason: str) -> bytes:
292
+ return _PAGE_TEMPLATE.substitute(
293
+ title="haru CLI authentication",
294
+ variant="denial",
295
+ stroke="#e74c3c",
296
+ icon=_CROSS_ICON,
297
+ heading="Request denied",
298
+ message=reason,
299
+ footer="You can close this window and re-run haru login",
300
+ ).encode("utf-8")
301
+
302
+
183
303
  def _start_loopback_server(port: int) -> _CallbackServer:
184
304
  """Bind the loopback callback server (port 0 selects an ephemeral port)."""
185
305
  try:
@@ -151,6 +151,21 @@ def _get(url: str) -> None:
151
151
  pass
152
152
 
153
153
 
154
+ class _Capture:
155
+ """Fetch a URL from a thread, capturing body, headers, and status."""
156
+
157
+ def __init__(self) -> None:
158
+ self.body = ""
159
+ self.headers: dict[str, str] = {}
160
+ self.status = 0
161
+
162
+ def fetch(self, url: str) -> None:
163
+ with urllib.request.urlopen(url) as response: # noqa: S310 - loopback URL
164
+ self.status = response.status
165
+ self.headers = {key.lower(): value for key, value in response.headers.items()}
166
+ self.body = response.read().decode("utf-8")
167
+
168
+
154
169
  def test_loopback_callback_roundtrip() -> None:
155
170
  """The loopback server captures code and state from a real local request."""
156
171
  server = _start_loopback_server(0)
@@ -194,7 +209,7 @@ def test_callback_ignores_other_paths() -> None:
194
209
 
195
210
  def _hit_wrong_then_right() -> None:
196
211
  with contextlib.suppress(urllib.error.HTTPError):
197
- _get(f"http://127.0.0.1:{port}/favicon.ico")
212
+ _get(f"http://127.0.0.1:{port}/wrong-path")
198
213
  _get(f"http://127.0.0.1:{port}/oauth/callback?code=abc&state=xyz")
199
214
 
200
215
  thread = threading.Thread(target=_hit_wrong_then_right)
@@ -206,6 +221,110 @@ def test_callback_ignores_other_paths() -> None:
206
221
  assert (code, state) == ("abc", "xyz")
207
222
 
208
223
 
224
+ def _serve_one(url: str, server: Any) -> _Capture:
225
+ """Serve exactly one request to ``url``, returning the captured response."""
226
+ capture = _Capture()
227
+ thread = threading.Thread(target=capture.fetch, args=(url,))
228
+ thread.start()
229
+ server.timeout = 10.0
230
+ server.handle_request()
231
+ thread.join(timeout=10.0)
232
+ return capture
233
+
234
+
235
+ def test_success_page_content_and_security_headers() -> None:
236
+ """A valid callback renders the success page with strict browser headers."""
237
+ server = _start_loopback_server(0)
238
+ try:
239
+ server.expected_state = "xyz"
240
+ port = server.server_address[1]
241
+ capture = _serve_one(f"http://127.0.0.1:{port}/oauth/callback?code=abc&state=xyz", server)
242
+ finally:
243
+ server.server_close()
244
+
245
+ assert capture.status == 200
246
+ assert "Request approved" in capture.body
247
+ assert "start using haru CLI" in capture.body
248
+ assert capture.headers["content-type"] == "text/html; charset=utf-8"
249
+ assert capture.headers["content-length"] == str(len(capture.body.encode("utf-8")))
250
+ csp = capture.headers["content-security-policy"]
251
+ assert "default-src 'none'" in csp
252
+ assert "frame-ancestors 'none'" in csp
253
+ assert capture.headers["x-content-type-options"] == "nosniff"
254
+ assert capture.headers["x-frame-options"] == "DENY"
255
+ assert capture.headers["referrer-policy"] == "no-referrer"
256
+ assert capture.headers["cache-control"] == "no-store"
257
+ assert "<script" not in capture.body
258
+ assert "src=" not in capture.body
259
+ assert "href=" not in capture.body
260
+
261
+
262
+ def test_denied_callback_renders_failure_page() -> None:
263
+ """An error callback shows the failure page, and the CLI still errors."""
264
+ server = _start_loopback_server(0)
265
+ try:
266
+ port = server.server_address[1]
267
+ url = (
268
+ f"http://127.0.0.1:{port}/oauth/callback"
269
+ "?error=access_denied&error_description=User+denied"
270
+ )
271
+ capture = _serve_one(url, server)
272
+ with pytest.raises(AuthError, match="access_denied"):
273
+ _await_callback(server, timeout_seconds=1.0)
274
+ finally:
275
+ server.server_close()
276
+
277
+ assert "Request denied" in capture.body
278
+ assert "access_denied" in capture.body
279
+ assert "Request approved" not in capture.body
280
+
281
+
282
+ def test_forged_state_renders_failure_page() -> None:
283
+ """A state mismatch shows the failure page in the browser."""
284
+ server = _start_loopback_server(0)
285
+ try:
286
+ server.expected_state = "expected"
287
+ port = server.server_address[1]
288
+ capture = _serve_one(
289
+ f"http://127.0.0.1:{port}/oauth/callback?code=abc&state=forged", server
290
+ )
291
+ finally:
292
+ server.server_close()
293
+
294
+ assert "Request denied" in capture.body
295
+ assert "security check" in capture.body
296
+
297
+
298
+ def test_error_description_is_escaped() -> None:
299
+ """HTML in the error description cannot inject markup into the page."""
300
+ server = _start_loopback_server(0)
301
+ try:
302
+ port = server.server_address[1]
303
+ url = (
304
+ f"http://127.0.0.1:{port}/oauth/callback"
305
+ "?error=access_denied&error_description=%3Cscript%3Ealert(1)%3C/script%3E"
306
+ )
307
+ capture = _serve_one(url, server)
308
+ finally:
309
+ server.server_close()
310
+
311
+ assert "<script" not in capture.body
312
+ assert "&lt;script&gt;" in capture.body
313
+
314
+
315
+ def test_favicon_request_is_quiet() -> None:
316
+ """Browsers requesting /favicon.ico get an empty 204, not an error page."""
317
+ server = _start_loopback_server(0)
318
+ try:
319
+ port = server.server_address[1]
320
+ capture = _serve_one(f"http://127.0.0.1:{port}/favicon.ico", server)
321
+ finally:
322
+ server.server_close()
323
+
324
+ assert capture.status == 204
325
+ assert capture.body == ""
326
+
327
+
209
328
  def test_callback_missing_state() -> None:
210
329
  """A callback without a state parameter raises AuthError."""
211
330
  server = _start_loopback_server(0)
@@ -633,7 +633,7 @@ wheels = [
633
633
 
634
634
  [[package]]
635
635
  name = "haru-cli"
636
- version = "0.1.3"
636
+ version = "0.1.4"
637
637
  source = { editable = "." }
638
638
  dependencies = [
639
639
  { name = "boto3" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes