haru-cli 0.1.2__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.
- {haru_cli-0.1.2 → haru_cli-0.1.4}/CHANGELOG.md +29 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/PKG-INFO +8 -3
- {haru_cli-0.1.2 → haru_cli-0.1.4}/README.md +7 -2
- {haru_cli-0.1.2 → haru_cli-0.1.4}/config/haru.yaml +4 -2
- {haru_cli-0.1.2 → haru_cli-0.1.4}/pyproject.toml +1 -1
- haru_cli-0.1.4/src/haru/auth/identity.py +169 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/auth/session.py +24 -8
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/auth/sso.py +126 -6
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/config.py +2 -6
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/login.py +24 -2
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/schema.py +9 -3
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/haru.yaml +4 -2
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_cli.py +14 -1
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_config_command.py +0 -2
- haru_cli-0.1.4/tests/unit/test_identity.py +172 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_session.py +60 -4
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_sso.py +120 -1
- {haru_cli-0.1.2 → haru_cli-0.1.4}/uv.lock +1 -1
- {haru_cli-0.1.2 → haru_cli-0.1.4}/.github/workflows/ci.yml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/.github/workflows/release.yml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/.gitignore +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/.kiro/steering/coding-standards.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/.kiro/steering/git-workflow.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/.kiro/steering/product.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/.kiro/steering/security.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/.kiro/steering/structure.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/.kiro/steering/tech.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/.kiro/steering/testing.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/.pre-commit-config.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/.python-version +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/CLAUDE.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/LICENSE +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/config/agents.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/config/guardrails.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/config/logging.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/config/mcp.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/config/models.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/config/prompts/researcher.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/config/prompts/supervisor.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/config/prompts/writer.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/docs/configuration.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/renovate.json +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/__main__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/agents/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/agents/factory.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/agents/orchestration.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/auth/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/auth/cache.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/auth/pkce.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/cli.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/agents.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/chat.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/run.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/session.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/streaming.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/loader.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/agents.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/logging.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/mcp.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/models.yaml +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/prompts/researcher.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/prompts/supervisor.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/prompts/writer.md +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/errors.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/models/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/models/bedrock.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/observability/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/observability/guardrails.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/observability/telemetry.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/sessions/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/sessions/manager.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/steering/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/steering/prompts.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/tools/__init__.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/tools/mcp.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/tools/registry.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/conftest.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/integration/test_packaging.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_agent_factory.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_bedrock.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_cache.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_config_loader.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_guardrails.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_main.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_mcp.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_orchestration.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_pkce.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_prompts.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_sessions.py +0 -0
- {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_telemetry.py +0 -0
|
@@ -9,6 +9,35 @@ 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
|
+
|
|
28
|
+
- Browser-only login: `haru login` now discovers your accessible AWS
|
|
29
|
+
accounts and permission-set roles from Identity Center after sign-in
|
|
30
|
+
(auto-selected when unambiguous, prompted otherwise) and remembers the
|
|
31
|
+
choice — no IAM role name or account id env var needed upfront.
|
|
32
|
+
`auth.sso.account_id`/`role_name` remain available as optional pins.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- `haru config init` no longer asks for an IAM role name; the
|
|
37
|
+
`HARU_AWS_ACCOUNT_ID` step is gone from the quickstart.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
12
41
|
- Sampling controls (`temperature`, `top_p`, `top_k`, `seed`) across three
|
|
13
42
|
surfaces: model entries, per-agent `sampling:` blocks, and CLI/REPL
|
|
14
43
|
overrides (`--temperature/--top-p/--top-k/--seed`, `/sampling`).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: haru-cli
|
|
3
|
-
Version: 0.1.
|
|
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
|
|
@@ -84,12 +84,17 @@ Inside `haru chat`, slash commands switch targets mid-session:
|
|
|
84
84
|
|
|
85
85
|
```bash
|
|
86
86
|
haru config init # writes ~/.config/haru (asks for your SSO start URL)
|
|
87
|
-
|
|
88
|
-
haru login
|
|
87
|
+
haru login # browser sign-in; account and role discovered for you
|
|
89
88
|
haru run "Summarise the Converse API in two sentences."
|
|
90
89
|
haru chat --agent supervisor --session-id demo
|
|
91
90
|
```
|
|
92
91
|
|
|
92
|
+
`haru login` is browser-only: after Identity Center sign-in, your accessible
|
|
93
|
+
AWS accounts and permission-set roles are discovered automatically (like
|
|
94
|
+
`aws configure sso`), auto-selected when there is exactly one, prompted
|
|
95
|
+
otherwise, and remembered for later sessions. Pin `auth.sso.account_id` /
|
|
96
|
+
`role_name` in configuration only if you want to skip the discovery.
|
|
97
|
+
|
|
93
98
|
Authentication uses the same PKCE authorization-code flow as `aws sso login`,
|
|
94
99
|
caching tokens in the botocore-compatible schema under `~/.aws/sso/cache`
|
|
95
100
|
(0600) so standard AWS tooling can consume and refresh the same cache. Access
|
|
@@ -56,12 +56,17 @@ Inside `haru chat`, slash commands switch targets mid-session:
|
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
58
|
haru config init # writes ~/.config/haru (asks for your SSO start URL)
|
|
59
|
-
|
|
60
|
-
haru login
|
|
59
|
+
haru login # browser sign-in; account and role discovered for you
|
|
61
60
|
haru run "Summarise the Converse API in two sentences."
|
|
62
61
|
haru chat --agent supervisor --session-id demo
|
|
63
62
|
```
|
|
64
63
|
|
|
64
|
+
`haru login` is browser-only: after Identity Center sign-in, your accessible
|
|
65
|
+
AWS accounts and permission-set roles are discovered automatically (like
|
|
66
|
+
`aws configure sso`), auto-selected when there is exactly one, prompted
|
|
67
|
+
otherwise, and remembered for later sessions. Pin `auth.sso.account_id` /
|
|
68
|
+
`role_name` in configuration only if you want to skip the discovery.
|
|
69
|
+
|
|
65
70
|
Authentication uses the same PKCE authorization-code flow as `aws sso login`,
|
|
66
71
|
caching tokens in the botocore-compatible schema under `~/.aws/sso/cache`
|
|
67
72
|
(0600) so standard AWS tooling can consume and refresh the same cache. Access
|
|
@@ -8,8 +8,10 @@ auth:
|
|
|
8
8
|
registration_scopes:
|
|
9
9
|
- sso:account:access
|
|
10
10
|
client_name: haru-cli
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
# Optional pins. When unset, your AWS account and role are discovered
|
|
12
|
+
# during 'haru login' (browser sign-in) and remembered.
|
|
13
|
+
# account_id: "123456789012"
|
|
14
|
+
# role_name: HaruBedrockInvoke
|
|
13
15
|
callback_port: 0 # 0 selects an ephemeral loopback port
|
|
14
16
|
browser: true
|
|
15
17
|
bedrock_region: us-east-1
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Discover and persist the AWS account and role behind an SSO sign-in.
|
|
2
|
+
|
|
3
|
+
Kiro-style browser sign-in needs no upfront AWS knowledge: after the token
|
|
4
|
+
exchange, the accessible accounts and permission-set roles are listed from
|
|
5
|
+
IAM Identity Center (as ``aws configure sso`` does), auto-selected when there
|
|
6
|
+
is exactly one, and persisted next to the token cache for later sessions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import boto3
|
|
17
|
+
from botocore.exceptions import ClientError
|
|
18
|
+
|
|
19
|
+
from haru.auth.cache import cache_path
|
|
20
|
+
from haru.auth.sso import SsoToken
|
|
21
|
+
from haru.config.schema import SsoConfig
|
|
22
|
+
from haru.errors import AuthError
|
|
23
|
+
|
|
24
|
+
Chooser = Callable[[str, list[str]], int]
|
|
25
|
+
"""Callback picking one option: (question, labels) -> chosen index."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class SelectedIdentity:
|
|
30
|
+
"""The AWS account and role selected for Bedrock access."""
|
|
31
|
+
|
|
32
|
+
account_id: str
|
|
33
|
+
role_name: str
|
|
34
|
+
account_name: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def identity_path(start_url: str, cache_dir: Path | None = None) -> Path:
|
|
38
|
+
"""Return the persisted-identity path for ``start_url``."""
|
|
39
|
+
token_path = cache_path(start_url, cache_dir)
|
|
40
|
+
return token_path.with_name(f"{token_path.stem}-haru-identity.json")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def write_identity(
|
|
44
|
+
identity: SelectedIdentity, start_url: str, cache_dir: Path | None = None
|
|
45
|
+
) -> Path:
|
|
46
|
+
"""Persist the selected identity next to the token cache."""
|
|
47
|
+
path = identity_path(start_url, cache_dir)
|
|
48
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
49
|
+
payload = {
|
|
50
|
+
"accountId": identity.account_id,
|
|
51
|
+
"roleName": identity.role_name,
|
|
52
|
+
"accountName": identity.account_name,
|
|
53
|
+
}
|
|
54
|
+
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
55
|
+
path.chmod(0o600)
|
|
56
|
+
return path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read_identity(start_url: str, cache_dir: Path | None = None) -> SelectedIdentity | None:
|
|
60
|
+
"""Read the persisted identity; None when absent or unreadable."""
|
|
61
|
+
path = identity_path(start_url, cache_dir)
|
|
62
|
+
if not path.is_file():
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
66
|
+
return SelectedIdentity(
|
|
67
|
+
account_id=payload["accountId"],
|
|
68
|
+
role_name=payload["roleName"],
|
|
69
|
+
account_name=payload.get("accountName"),
|
|
70
|
+
)
|
|
71
|
+
except (json.JSONDecodeError, KeyError):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def select_identity(
|
|
76
|
+
sso_cfg: SsoConfig,
|
|
77
|
+
token: SsoToken,
|
|
78
|
+
*,
|
|
79
|
+
sso_client: Any | None = None,
|
|
80
|
+
cache_dir: Path | None = None,
|
|
81
|
+
chooser: Chooser | None = None,
|
|
82
|
+
) -> SelectedIdentity:
|
|
83
|
+
"""Resolve the account and role for this sign-in and persist the choice.
|
|
84
|
+
|
|
85
|
+
Configuration pins win (``account_id``/``role_name``, or the account id
|
|
86
|
+
from ``account_id_env``); anything unpinned is discovered from Identity
|
|
87
|
+
Center. With multiple choices and no ``chooser``, raises AuthError.
|
|
88
|
+
"""
|
|
89
|
+
if sso_client is None:
|
|
90
|
+
sso_client = boto3.Session().client("sso", region_name=sso_cfg.sso_region)
|
|
91
|
+
|
|
92
|
+
account_id = _pinned_account(sso_cfg)
|
|
93
|
+
account_name: str | None = None
|
|
94
|
+
if account_id is None:
|
|
95
|
+
accounts = _list_accounts(sso_client, token.access_token)
|
|
96
|
+
chosen = _choose(
|
|
97
|
+
"Choose the AWS account to use",
|
|
98
|
+
accounts,
|
|
99
|
+
lambda account: f"{account['accountId']} {account.get('accountName', '')}".strip(),
|
|
100
|
+
chooser,
|
|
101
|
+
)
|
|
102
|
+
account_id = chosen["accountId"]
|
|
103
|
+
account_name = chosen.get("accountName")
|
|
104
|
+
|
|
105
|
+
role_name = sso_cfg.role_name
|
|
106
|
+
if role_name is None:
|
|
107
|
+
roles = _list_roles(sso_client, token.access_token, account_id)
|
|
108
|
+
role_name = _choose(f"Choose the role for account {account_id}", roles, str, chooser)
|
|
109
|
+
|
|
110
|
+
identity = SelectedIdentity(
|
|
111
|
+
account_id=account_id, role_name=role_name, account_name=account_name
|
|
112
|
+
)
|
|
113
|
+
write_identity(identity, sso_cfg.start_url, cache_dir)
|
|
114
|
+
return identity
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _pinned_account(sso_cfg: SsoConfig) -> str | None:
|
|
118
|
+
if sso_cfg.account_id is not None:
|
|
119
|
+
return sso_cfg.account_id
|
|
120
|
+
if sso_cfg.account_id_env is not None:
|
|
121
|
+
return os.environ.get(sso_cfg.account_id_env)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _list_accounts(sso_client: Any, access_token: str) -> list[dict[str, Any]]:
|
|
126
|
+
try:
|
|
127
|
+
paginator = sso_client.get_paginator("list_accounts")
|
|
128
|
+
accounts = [
|
|
129
|
+
account
|
|
130
|
+
for page in paginator.paginate(accessToken=access_token)
|
|
131
|
+
for account in page.get("accountList", [])
|
|
132
|
+
]
|
|
133
|
+
except ClientError as exc:
|
|
134
|
+
raise AuthError(f"Listing accessible AWS accounts failed: {exc}") from exc
|
|
135
|
+
if not accounts:
|
|
136
|
+
raise AuthError("This sign-in grants access to no AWS accounts; contact your admin")
|
|
137
|
+
return sorted(accounts, key=lambda account: str(account["accountId"]))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _list_roles(sso_client: Any, access_token: str, account_id: str) -> list[str]:
|
|
141
|
+
try:
|
|
142
|
+
paginator = sso_client.get_paginator("list_account_roles")
|
|
143
|
+
roles = [
|
|
144
|
+
role["roleName"]
|
|
145
|
+
for page in paginator.paginate(accessToken=access_token, accountId=account_id)
|
|
146
|
+
for role in page.get("roleList", [])
|
|
147
|
+
]
|
|
148
|
+
except ClientError as exc:
|
|
149
|
+
raise AuthError(f"Listing roles for account {account_id} failed: {exc}") from exc
|
|
150
|
+
if not roles:
|
|
151
|
+
raise AuthError(f"No roles are available in account {account_id}; contact your admin")
|
|
152
|
+
return sorted(roles)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _choose[Item](
|
|
156
|
+
question: str,
|
|
157
|
+
options: list[Item],
|
|
158
|
+
label: Callable[[Item], str],
|
|
159
|
+
chooser: Chooser | None,
|
|
160
|
+
) -> Item:
|
|
161
|
+
if len(options) == 1:
|
|
162
|
+
return options[0]
|
|
163
|
+
if chooser is None:
|
|
164
|
+
raise AuthError(
|
|
165
|
+
f"{question}: multiple options available; re-run 'haru login' interactively"
|
|
166
|
+
" or pin the choice in configuration"
|
|
167
|
+
)
|
|
168
|
+
index = chooser(question, [label(option) for option in options])
|
|
169
|
+
return options[index]
|
|
@@ -14,9 +14,10 @@ from botocore.exceptions import (
|
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
from haru.auth.cache import read_token_cache, write_token_cache
|
|
17
|
+
from haru.auth.identity import read_identity
|
|
17
18
|
from haru.auth.sso import SsoToken
|
|
18
|
-
from haru.config.schema import AuthConfig
|
|
19
|
-
from haru.errors import AuthExpiredError
|
|
19
|
+
from haru.config.schema import AuthConfig, SsoConfig
|
|
20
|
+
from haru.errors import AuthExpiredError
|
|
20
21
|
|
|
21
22
|
_REFRESH_WINDOW = timedelta(minutes=15)
|
|
22
23
|
_RELOGIN_HINT = "run 'haru login'"
|
|
@@ -42,16 +43,12 @@ def build_boto3_session(
|
|
|
42
43
|
if token.expires_at <= datetime.now(UTC) + _REFRESH_WINDOW:
|
|
43
44
|
token = _refresh_token(config, token, oidc=oidc, cache_dir=cache_dir)
|
|
44
45
|
|
|
45
|
-
account_id =
|
|
46
|
-
if account_id is None:
|
|
47
|
-
raise ConfigError(
|
|
48
|
-
f"Environment variable {sso.account_id_env!r} must hold the target AWS account id"
|
|
49
|
-
)
|
|
46
|
+
account_id, role_name = _resolve_identity(sso, cache_dir)
|
|
50
47
|
if sso_client is None:
|
|
51
48
|
sso_client = boto3.Session().client("sso", region_name=sso.sso_region)
|
|
52
49
|
try:
|
|
53
50
|
response = sso_client.get_role_credentials(
|
|
54
|
-
roleName=
|
|
51
|
+
roleName=role_name, accountId=account_id, accessToken=token.access_token
|
|
55
52
|
)
|
|
56
53
|
except (ClientError, SSOTokenLoadError, UnauthorizedSSOTokenError, TokenRetrievalError) as exc:
|
|
57
54
|
raise AuthExpiredError(f"SSO credentials rejected; {_RELOGIN_HINT}") from exc
|
|
@@ -65,6 +62,25 @@ def build_boto3_session(
|
|
|
65
62
|
)
|
|
66
63
|
|
|
67
64
|
|
|
65
|
+
def _resolve_identity(sso: SsoConfig, cache_dir: Path | None) -> tuple[str, str]:
|
|
66
|
+
"""Resolve the account id and role: config pins, env var, then the login choice."""
|
|
67
|
+
stored = read_identity(sso.start_url, cache_dir)
|
|
68
|
+
|
|
69
|
+
account_id = sso.account_id
|
|
70
|
+
if account_id is None and sso.account_id_env is not None:
|
|
71
|
+
account_id = os.environ.get(sso.account_id_env)
|
|
72
|
+
if account_id is None and stored is not None:
|
|
73
|
+
account_id = stored.account_id
|
|
74
|
+
|
|
75
|
+
role_name = sso.role_name
|
|
76
|
+
if role_name is None and stored is not None:
|
|
77
|
+
role_name = stored.role_name
|
|
78
|
+
|
|
79
|
+
if account_id is None or role_name is None:
|
|
80
|
+
raise AuthExpiredError(f"No AWS account/role selected yet; {_RELOGIN_HINT}")
|
|
81
|
+
return account_id, role_name
|
|
82
|
+
|
|
83
|
+
|
|
68
84
|
def _refresh_token(
|
|
69
85
|
config: AuthConfig, token: SsoToken, *, oidc: Any | None, cache_dir: Path | None
|
|
70
86
|
) -> SsoToken:
|
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
@@ -65,7 +65,6 @@ def config() -> None:
|
|
|
65
65
|
"--sso-region", default="us-east-1", prompt="Identity Center region", show_default=True
|
|
66
66
|
)
|
|
67
67
|
@click.option("--bedrock-region", default="us-east-1", prompt="Bedrock region", show_default=True)
|
|
68
|
-
@click.option("--role-name", default="HaruBedrockInvoke", prompt="IAM role name", show_default=True)
|
|
69
68
|
@click.option(
|
|
70
69
|
"--guardrail-id",
|
|
71
70
|
default="",
|
|
@@ -78,7 +77,6 @@ def init( # noqa: PLR0913, PLR0917 - one option per prompted setting; Click pas
|
|
|
78
77
|
start_url: str,
|
|
79
78
|
sso_region: str,
|
|
80
79
|
bedrock_region: str,
|
|
81
|
-
role_name: str,
|
|
82
80
|
guardrail_id: str,
|
|
83
81
|
force: bool,
|
|
84
82
|
) -> None:
|
|
@@ -92,7 +90,6 @@ def init( # noqa: PLR0913, PLR0917 - one option per prompted setting; Click pas
|
|
|
92
90
|
"__START_URL__": start_url,
|
|
93
91
|
"__SSO_REGION__": sso_region,
|
|
94
92
|
"__BEDROCK_REGION__": bedrock_region,
|
|
95
|
-
"__ROLE_NAME__": role_name,
|
|
96
93
|
}
|
|
97
94
|
templates = resources.files("haru.config.templates")
|
|
98
95
|
(target / "prompts").mkdir(parents=True, exist_ok=True)
|
|
@@ -113,9 +110,8 @@ def init( # noqa: PLR0913, PLR0917 - one option per prompted setting; Click pas
|
|
|
113
110
|
|
|
114
111
|
click.echo(f"Configuration written to {target}.")
|
|
115
112
|
click.echo("Next steps:")
|
|
116
|
-
click.echo(" 1.
|
|
117
|
-
click.echo(" 2. haru
|
|
118
|
-
click.echo(" 3. haru chat")
|
|
113
|
+
click.echo(" 1. haru login (browser sign-in; your account and role are discovered)")
|
|
114
|
+
click.echo(" 2. haru chat")
|
|
119
115
|
|
|
120
116
|
|
|
121
117
|
@config.command()
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
"""The ``haru login`` command: interactive IAM Identity Center sign-in.
|
|
1
|
+
"""The ``haru login`` command: interactive IAM Identity Center sign-in.
|
|
2
|
+
|
|
3
|
+
Browser-only sign-in: no IAM role or account id needs to be known upfront.
|
|
4
|
+
After the token exchange, the accessible accounts and roles are discovered
|
|
5
|
+
from Identity Center, auto-selected when unambiguous, and remembered.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
import webbrowser
|
|
4
9
|
from pathlib import Path
|
|
@@ -6,18 +11,28 @@ from pathlib import Path
|
|
|
6
11
|
import click
|
|
7
12
|
|
|
8
13
|
from haru.auth.cache import write_token_cache
|
|
14
|
+
from haru.auth.identity import select_identity
|
|
9
15
|
from haru.auth.sso import run_login
|
|
10
16
|
from haru.config import load_config
|
|
11
17
|
from haru.errors import HaruError
|
|
12
18
|
|
|
13
19
|
|
|
20
|
+
def _prompt_choice(question: str, labels: list[str]) -> int:
|
|
21
|
+
"""Interactive numbered chooser for accounts and roles."""
|
|
22
|
+
click.echo(f"{question}:")
|
|
23
|
+
for number, label in enumerate(labels, start=1):
|
|
24
|
+
click.echo(f" {number}. {label}")
|
|
25
|
+
choice: int = click.prompt("Enter a number", type=click.IntRange(1, len(labels)))
|
|
26
|
+
return choice - 1
|
|
27
|
+
|
|
28
|
+
|
|
14
29
|
@click.command()
|
|
15
30
|
@click.option(
|
|
16
31
|
"--config",
|
|
17
32
|
"config_path",
|
|
18
33
|
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
19
34
|
default=None,
|
|
20
|
-
help="Path to haru.yaml (default: config/haru.yaml).",
|
|
35
|
+
help="Path to haru.yaml (default: ./config/haru.yaml, then ~/.config/haru/haru.yaml).",
|
|
21
36
|
)
|
|
22
37
|
def login(config_path: Path | None) -> None:
|
|
23
38
|
"""Sign in to AWS IAM Identity Center via the browser (PKCE)."""
|
|
@@ -33,6 +48,13 @@ def login(config_path: Path | None) -> None:
|
|
|
33
48
|
|
|
34
49
|
token = run_login(auth, opener=_open)
|
|
35
50
|
path = write_token_cache(token, auth.sso.start_url, auth.sso.sso_region)
|
|
51
|
+
identity = select_identity(auth.sso, token, chooser=_prompt_choice)
|
|
36
52
|
except HaruError as exc:
|
|
37
53
|
raise click.ClickException(str(exc)) from exc
|
|
38
54
|
click.echo(f"Login successful. Token cached at {path}.")
|
|
55
|
+
account_label = (
|
|
56
|
+
f"{identity.account_id} ({identity.account_name})"
|
|
57
|
+
if identity.account_name
|
|
58
|
+
else identity.account_id
|
|
59
|
+
)
|
|
60
|
+
click.echo(f"Using account {account_label}, role {identity.role_name}.")
|
|
@@ -24,14 +24,20 @@ class AppConfig(_FrozenModel):
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
class SsoConfig(_FrozenModel):
|
|
27
|
-
"""IAM Identity Center (SSO) login settings.
|
|
27
|
+
"""IAM Identity Center (SSO) login settings.
|
|
28
|
+
|
|
29
|
+
``account_id`` and ``role_name`` are optional: when unset, the account
|
|
30
|
+
and permission-set role are discovered interactively during
|
|
31
|
+
``haru login`` (browser-only sign-in, like the AWS CLI's SSO flow).
|
|
32
|
+
"""
|
|
28
33
|
|
|
29
34
|
start_url: str
|
|
30
35
|
sso_region: str
|
|
31
36
|
registration_scopes: tuple[str, ...] = ("sso:account:access",)
|
|
32
37
|
client_name: str = "haru-cli"
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
account_id: str | None = None
|
|
39
|
+
account_id_env: str | None = None
|
|
40
|
+
role_name: str | None = None
|
|
35
41
|
callback_port: int = Field(default=0, ge=0, le=65535)
|
|
36
42
|
browser: bool = True
|
|
37
43
|
|
|
@@ -8,8 +8,10 @@ auth:
|
|
|
8
8
|
registration_scopes:
|
|
9
9
|
- sso:account:access
|
|
10
10
|
client_name: haru-cli
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
# Optional pins. When unset, your AWS account and role are discovered
|
|
12
|
+
# during 'haru login' (browser sign-in) and remembered.
|
|
13
|
+
# account_id: "123456789012"
|
|
14
|
+
# role_name: BedrockAccess
|
|
13
15
|
callback_port: 0 # 0 selects an ephemeral loopback port
|
|
14
16
|
browser: true
|
|
15
17
|
bedrock_region: __BEDROCK_REGION__
|