From 1e6c9fa377b5a1005a79227a90469a5815777249 Mon Sep 17 00:00:00 2001 From: petrsnd Date: Fri, 15 May 2026 19:05:06 -0600 Subject: [PATCH 1/3] Create skills for use in repo --- .agents/skills/api-patterns/SKILL.md | 356 ++++++++++++++++++++++++++ .agents/skills/architecture/SKILL.md | 341 ++++++++++++++++++++++++ .agents/skills/testing-guide/SKILL.md | 197 ++++++++++++++ 3 files changed, 894 insertions(+) create mode 100644 .agents/skills/api-patterns/SKILL.md create mode 100644 .agents/skills/architecture/SKILL.md create mode 100644 .agents/skills/testing-guide/SKILL.md diff --git a/.agents/skills/api-patterns/SKILL.md b/.agents/skills/api-patterns/SKILL.md new file mode 100644 index 0000000..b6bc68d --- /dev/null +++ b/.agents/skills/api-patterns/SKILL.md @@ -0,0 +1,356 @@ +--- +name: api-patterns +description: >- + Use when making Safeguard API calls via SafeguardClient, working with + HTTP methods, streaming, error handling, A2A credential retrieval, + or managing token lifecycle. Covers method signatures, parameter + conventions, and common patterns. +--- + +# API Patterns + +## HTTP Methods + +All methods require an authenticated client (call `login()` or use a context +manager first). The first two positional arguments are always `service` and +`endpoint`. + +### GET + +```python +client.get( + service: Service, + endpoint: str | None = None, + *, + params: Mapping[str, str] | None = None, + headers: Mapping[str, str] | None = None, + host: str | None = None, + cert: tuple[str, str] | None = None, + api_version: str | None = None, +) -> requests.Response +``` + +### POST + +```python +client.post( + service: Service, + endpoint: str | None = None, + *, + json: JsonType | None = None, + data: str | None = None, + params: Mapping[str, str] | None = None, + headers: Mapping[str, str] | None = None, + host: str | None = None, + cert: tuple[str, str] | None = None, + api_version: str | None = None, +) -> requests.Response +``` + +### PUT + +```python +client.put( + service: Service, + endpoint: str | None = None, + *, + json: JsonType | None = None, + data: str | None = None, + params: Mapping[str, str] | None = None, + headers: Mapping[str, str] | None = None, + host: str | None = None, + cert: tuple[str, str] | None = None, + api_version: str | None = None, +) -> requests.Response +``` + +### DELETE + +```python +client.delete( + service: Service, + endpoint: str | None = None, + *, + params: Mapping[str, str] | None = None, + headers: Mapping[str, str] | None = None, + host: str | None = None, + cert: tuple[str, str] | None = None, + api_version: str | None = None, +) -> requests.Response +``` + +### request (low-level) + +```python +client.request( + method: HttpMethod, + service: Service, + endpoint: str | None = None, + *, + params: Mapping[str, str] | None = None, + json: JsonType | None = None, + data: str | None = None, + headers: Mapping[str, str] | None = None, + host: str | None = None, + cert: tuple[str, str] | None = None, + api_version: str | None = None, +) -> requests.Response +``` + +## Parameter Reference + +| Parameter | Type | Description | +|-----------|------|-------------| +| `service` | `Service` | Target API service (CORE, APPLIANCE, etc.) | +| `endpoint` | `str \| None` | URL path after the service prefix (e.g., `"Users"`, `"Assets/123"`) | +| `json` | `JsonType \| None` | Dict/list body — auto-sets `Content-Type: application/json` | +| `data` | `str \| None` | Raw string body — no automatic content-type | +| `params` | `Mapping[str, str] \| None` | Query parameters appended to URL | +| `headers` | `Mapping[str, str] \| None` | Additional headers merged with defaults | +| `host` | `str \| None` | Override target host (useful for clusters) | +| `cert` | `tuple[str, str] \| None` | Client certificate as `(cert_file, key_file)` | +| `api_version` | `str \| None` | Override API version for this request (default: `"v4"`) | + +### `json` vs `data` — Important Distinction + +- **`json=`** serializes a Python dict/list to JSON and sets + `Content-Type: application/json`. Use for structured API payloads. +- **`data=`** sends a raw string body with no automatic content-type. + Use for pre-serialized or non-JSON payloads. +- **Never pass both** — `json=` takes precedence if both are provided. + +## Streaming + +### stream — Raw streaming response + +```python +client.stream( + method: HttpMethod, service: Service, endpoint: str | None = None, + *, params=..., json=..., data=..., headers=..., host=..., cert=..., api_version=..., +) -> requests.Response +``` + +Returns an **unconsumed** response with `stream=True`. Caller is responsible +for iterating `response.iter_content()` or `response.iter_lines()`. + +### download — Stream to file + +```python +client.download( + service: Service, endpoint: str, file_path: str | Path, + *, params=..., headers=..., host=..., cert=..., api_version=..., + chunk_size: int = 8192, +) -> int # bytes written +``` + +### upload — Upload file or bytes + +```python +client.upload( + service: Service, endpoint: str, file_or_stream: str | Path | IO[bytes], + *, content_type: str = "application/octet-stream", + params=..., headers=..., host=..., cert=..., api_version=..., +) -> requests.Response +``` + +Accepts a file path (string/Path) or an open binary stream. + +## Safeguard API Services + +| Enum | URL path | Description | +|---|---|---| +| `Service.CORE` | `service/core` | Primary API: assets, users, policies, access requests | +| `Service.APPLIANCE` | `service/appliance` | Appliance management: networking, diagnostics, backups | +| `Service.NOTIFICATION` | `service/notification` | Anonymous status and notification endpoints | +| `Service.A2A` | `service/a2a` | Application-to-Application credential retrieval | +| `Service.EVENT` | `service/event` | SignalR event streaming | +| `Service.RSTS` | `RSTS` | Embedded secure token service (authentication) | + +The default API version is **v4** (since Safeguard 7.0). + +## Error Handling + +### Error Hierarchy + +``` +SafeguardError (base) +├── ApiError (HTTP error responses) +│ ├── AuthenticationError (401) +│ ├── AuthorizationError (403) +│ └── NotFoundError (404) +└── TransportError (network/connection failures) +``` + +### Error Attributes + +All `SafeguardError` subclasses carry: + +| Attribute | Type | Description | +|-----------|------|-------------| +| `status_code` | `int \| None` | HTTP status code | +| `error_code` | `int \| None` | Safeguard-specific error code (from response `Code` field) | +| `error_message` | `str \| None` | Safeguard error message (from response `Message` field) | +| `response_body` | `str \| None` | Raw response body text | + +### Automatic Status Code Mapping + +`ApiError.from_response(resp)` auto-maps HTTP status codes: + +- `401` → `AuthenticationError` +- `403` → `AuthorizationError` +- `404` → `NotFoundError` +- All others → `ApiError` + +The error message is formatted as: `"{status_code} {reason}: {method} {url}\n{body}"` + +### Catching Errors + +```python +from pysafeguard import SafeguardClient, Service, NotFoundError, ApiError + +with SafeguardClient(...) as client: + try: + user = client.get(Service.CORE, "Users/99999").json() + except NotFoundError: + print("User not found") + except ApiError as e: + print(f"API error {e.status_code}: {e.error_message}") +``` + +## Token Lifecycle + +### Manual refresh + +```python +client.refresh_access_token() +``` + +Requires the auth strategy to support refresh (`can_refresh=True`). +`PasswordAuth` and `CertificateAuth` support refresh. `TokenAuth` does not. +`PkceAuth` supports refresh only when no secondary password (MFA) is configured. + +### Check remaining lifetime + +```python +remaining = client.token_lifetime_remaining # int | None (seconds) +``` + +Queries `Service.APPLIANCE/SystemTime` and reads the +`x-tokenlifetimeremaining` response header. + +### Auto-refresh + +```python +client = SafeguardClient("host", auth=auth, auto_refresh=True) +``` + +When enabled, every `request()`, `stream()`, and `upload()` call checks +the token lifetime before executing. If the token is expired or missing, +it automatically calls `refresh_access_token()`. + +Auto-refresh is skipped for `Service.RSTS` and `Service.APPLIANCE` requests +to avoid circular refresh loops. + +### Logout + +```python +client.logout() +``` + +POSTs to `Service.CORE/Token/Logout` to invalidate the token on the +appliance, then clears the local token. Errors during logout are silently +ignored (best-effort). + +## A2A (Application-to-Application) + +### Context Manager Pattern + +```python +from pysafeguard import A2AContext + +with A2AContext("host", "cert.pem", "key.pem", verify=False) as ctx: + password = ctx.retrieve_password("my-api-key") + ctx.set_password("my-api-key", "new-password") + private_key = ctx.retrieve_private_key("my-api-key") + secret = ctx.retrieve_api_key_secret("my-api-key") +``` + +### Quick One-Shot Retrieval + +```python +password = A2AContext.quick_retrieve_password( + "host", "api-key", "cert.pem", "key.pem", verify=False, +) +private_key = A2AContext.quick_retrieve_private_key( + "host", "api-key", "cert.pem", "key.pem", + key_format=SshKeyFormat.OPENSSH, verify=False, +) +``` + +### A2A Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `retrieve_password(api_key)` | `HiddenString` | Retrieve managed password | +| `set_password(api_key, password)` | `None` | Update managed password | +| `retrieve_private_key(api_key, *, key_format=OPENSSH)` | `HiddenString` | Retrieve SSH private key | +| `set_private_key(api_key, key, passphrase, *, key_format=OPENSSH)` | `None` | Update SSH private key | +| `retrieve_api_key_secret(api_key)` | `JsonType` | Retrieve API key secret | +| `broker_access_request(api_key, access_request)` | `str` | Submit an access request | +| `get_retrievable_accounts(*, filter=None)` | `list[dict]` | List accounts (uses cert auth) | + +### A2A Authorization Header + +A2A requests use `Authorization: A2A ` (not Bearer). + +### A2A Gotcha: `set_password` Content-Type + +`set_password` sends the password via `json=` internally. If you're calling +the raw API yourself, you **must** use `Content-Type: application/json`. +Using `data=` (raw string) results in **415 Unsupported Media Type**. + +## Common Patterns + +### GET with query parameters + +```python +users = client.get( + Service.CORE, "Users", + params={"filter": "UserName eq 'admin'", "fields": "Id,UserName"}, +).json() +``` + +### POST with JSON body + +```python +response = client.post( + Service.CORE, "Users", + json={"Name": "NewUser", "PrimaryAuthenticationProvider": {"Id": provider_id}}, +) +new_user = response.json() +``` + +### Override API version for a single request + +```python +response = client.get(Service.CORE, "Users", api_version="v3") +``` + +### Override target host (cluster scenario) + +```python +response = client.get(Service.CORE, "Users", host="other-node.example.com") +``` + +## Async Client + +`AsyncSafeguardClient` mirrors the sync client exactly. All methods are +`async def` with the same signatures. Use `await` on every call: + +```python +async with AsyncSafeguardClient("host", auth=auth, verify=False) as client: + users = (await client.get(Service.CORE, "Users")).json() + await client.post(Service.CORE, "Users", json={"Name": "NewUser"}) +``` + +`AsyncA2AContext` similarly mirrors `A2AContext` with async methods. diff --git a/.agents/skills/architecture/SKILL.md b/.agents/skills/architecture/SKILL.md new file mode 100644 index 0000000..4291f79 --- /dev/null +++ b/.agents/skills/architecture/SKILL.md @@ -0,0 +1,341 @@ +--- +name: architecture +description: >- + Use when working on PySafeguard module internals, adding new auth + strategies, extending the client, working with the event system + (SignalR), or understanding internal patterns like PKCE, HiddenString, + or async lazy imports. +--- + +# Architecture + +## Authentication Flow + +The full login flow, step by step: + +1. **Construct client** — `SafeguardClient("host", auth=PasswordAuth(...))`. + No network I/O happens here (side-effect-free constructor). +2. **Call `login()`** (or enter a context manager, which calls it automatically). +3. **Auth strategy authenticates** — `auth.authenticate(client)` is called: + - Resolves the identity provider name to an ID via + `GET /RSTS/AuthenticationProviders` + - POSTs to `RSTS/oauth2/token` with grant-specific body + (password grant, client credentials, or PKCE code exchange) + - Returns an rSTS access token string +4. **rSTS → Safeguard token exchange** — the client POSTs the rSTS token to + `Service.CORE/Token/LoginResponse` and receives a Safeguard `UserToken`. +5. **Token stored** — `UserToken` is set as `Authorization: Bearer ` + on subsequent requests. +6. **Logout** — `client.logout()` POSTs to `Token/Logout`, then clears the + local token. + +## Auth Protocol + +Defined in `auth.py` as a `@runtime_checkable` `Protocol`: + +```python +class Auth(Protocol): + @property + def can_refresh(self) -> bool: ... + + def authenticate(self, client: SafeguardClient) -> str: ... + def refresh(self, client: SafeguardClient) -> str: ... + async def async_authenticate(self, client: AsyncSafeguardClient) -> str: ... + async def async_refresh(self, client: AsyncSafeguardClient) -> str: ... +``` + +All methods return an rSTS access token string. The client then exchanges +it for a `UserToken` internally. + +## Auth Strategy Implementations + +### PasswordAuth + +```python +PasswordAuth(provider: str, username: str, password: str | HiddenString) +``` + +- Uses Resource Owner Grant (`grant_type=password`) +- `can_refresh = True` +- Password auto-wrapped in `HiddenString` +- `dispose()` zeroes the password + +### CertificateAuth + +```python +CertificateAuth(cert_file: str, key_file: str, provider: str = "certificate") +``` + +- Uses client credentials grant (`grant_type=client_credentials`) +- `can_refresh = True` +- Sends cert via `cert=(cert_file, key_file)` tuple on requests +- No secrets to dispose (cert files are paths) + +### PkceAuth + +```python +PkceAuth( + provider: str, + username: str, + password: str | HiddenString, + secondary_password: str | HiddenString | None = None, +) +``` + +- Non-interactive browser-less PKCE flow (recommended for newer appliances) +- `can_refresh = True` only when `secondary_password is None` (no MFA) +- Both password and secondary_password auto-wrapped in `HiddenString` +- `dispose()` zeroes both secrets + +### TokenAuth + +```python +TokenAuth(token: str | HiddenString) +``` + +- Pre-existing bearer token, no refresh capability +- `can_refresh = False` +- `refresh()` always raises `SafeguardError` +- Token auto-wrapped in `HiddenString` + +## Adding a New Auth Strategy + +1. **Implement the `Auth` protocol** in `auth.py`: + - Define `can_refresh` property + - Implement `authenticate()` and `refresh()` (sync) + - Implement `async_authenticate()` and `async_refresh()` + - Wrap any secrets in `HiddenString` + - Add a `dispose()` method to zero sensitive fields + +2. **Export from `__init__.py`**: + - Import the class in the top-level imports + - Add to the `__all__` list + +3. **Add tests**: + - Unit test in `tests/test_auth.py` (protocol conformance + construction) + - Integration test in `tests/integration/` if the strategy involves live + appliance I/O + +4. **Update AGENTS.md**: + - Add to the auth strategies table in the always-on section + +## PKCE Non-Interactive Flow + +`pkce.py` implements the full rSTS PKCE authentication flow without a +browser. This is the **recommended** method on newer appliances where Resource +Owner Grant (ROG) is disabled by default. + +### Flow Steps + +1. Generate CSRF token, PKCE code verifier, and code challenge +2. Set `CsrfToken` cookie manually on the session +3. Resolve identity provider via `GET /RSTS/AuthenticationProviders` +4. POST `loginRequestStep=1` — initialize login +5. POST `loginRequestStep=3` — submit primary credentials (username/password) +6. If MFA required: + - POST `loginRequestStep=7` — initialize secondary auth + - POST `loginRequestStep=5` — submit secondary credentials +7. POST `loginRequestStep=6` — generate claims, extract authorization code + from the `RelyingPartyUrl` query string +8. Exchange authorization code for rSTS access token via + `POST /RSTS/oauth2/token` with `grant_type=authorization_code` +9. Exchange rSTS token for Safeguard `UserToken` via + `POST /service/core/v4/Token/LoginResponse` + +### Key Implementation Details + +- Uses `redirect_uri=urn:InstalledApplication` (no actual redirect) +- All requests are direct HTTP form posts to `/RSTS/UserLogin/LoginController` +- Provider resolution: exact ID match → exact name match → substring match +- `async_pkce.py` mirrors the sync flow using `aiohttp` + +## Event System + +### Components + +| Class | Purpose | +|-------|---------| +| `SafeguardEventListener` | One-shot SignalR listener with bearer token auth | +| `PersistentSafeguardEventListener` | Auto-reconnecting listener that re-authenticates on disconnect | +| `EventHandlerRegistry` | Thread-safe container for event name → handler mappings | +| `EventListenerState` | Enum: `STARTING`, `CONNECTED`, `DISCONNECTED`, `RECONNECTING`, `STOPPED` | + +### EventHandlerRegistry + +- Thread-safe with a `threading.Lock` +- Event names are case-folded for matching +- `register(event_name, handler)` — appends handler +- `handle_event(raw_event)` — parses JSON, dispatches by `Name` field +- **A2A workaround:** If `Name` is numeric, the real event name is extracted + from `Data.EventName` +- Handler exceptions are logged and swallowed (never crash the listener) + +### SafeguardEventListener Lifecycle + +```python +listener = SafeguardEventListener("host", access_token, verify=False) +listener.on("AssetCreated", my_handler) +listener.on_state_change(my_state_callback) +listener.start() +# ... events flow ... +listener.stop() +``` + +- `start()` builds a SignalR hub connection to `Service.EVENT/signalr` +- Configures bearer token factory, TLS options, and `JsonHubProtocol(version=1)` +- Registers for all events in the `EventHandlerRegistry` +- `stop()` stops the hub and emits `STOPPED` state +- Supports context manager (`with listener:`) + +### PersistentSafeguardEventListener + +Auto-reconnecting wrapper that re-authenticates when the connection drops. + +```python +listener = PersistentSafeguardEventListener.from_password( + "host", "local", "admin", "secret", verify=False, +) +listener.on("UserCreated", handler) +listener.start() +``` + +- **Factory methods:** `from_password(...)` and `from_certificate(...)` create + token factories that construct a fresh `SafeguardClient`, log in, and return + the token +- On `DISCONNECTED`: schedules reconnect on a daemon thread +- `_reconnect_loop()` sleeps `retry_seconds` (default 5.0), re-auths, reconnects +- Previous client is logged out best-effort on each reconnection +- `stop()` stops inner listener, joins reconnect thread, emits `STOPPED` + +### signalrcore Protocol Version Bug + +signalrcore 1.0.2 incorrectly uses `negotiateVersion` (the negotiate +*endpoint* protocol version) as the hub protocol version. Safeguard returns +`negotiateVersion: 0`, causing handshake failure. The workaround in +`_json_hub_protocol()` explicitly constructs `JsonHubProtocol(version=1)`. + +## HiddenString + +Wraps sensitive values to prevent casual exposure in logs, repr, and debugger +output. + +### Storage + +Uses mutable `bytearray` internally (not `str`) to enable explicit zeroing +on disposal. + +### API + +| Method/Property | Behavior | +|----------------|----------| +| `.value` | Returns decoded UTF-8 string; raises `ValueError` if disposed | +| `.get_value()` | **Deprecated** — alias for `.value` | +| `dispose()` | Zeroes each byte, sets internal buffer to `None` | +| `__repr__` | Always `"HiddenString(***)"` | +| `__str__` | Always `"***"` | +| `__bool__` | `False` if disposed or empty | +| `__len__` | Character count, or 0 if disposed | +| `__eq__` | Compares underlying bytes; two disposed instances are equal | +| `__hash__` | Raises `TypeError` (unhashable) | + +### Context Manager + +```python +with HiddenString("secret") as s: + print(s.value) # "secret" +# s is disposed here — s.value raises ValueError +``` + +### Copy/Pickle Blocking + +`__reduce_ex__`, `__getstate__`, `__copy__`, and `__deepcopy__` all raise +`TypeError("HiddenString cannot be pickled")` to prevent accidental +serialization of secrets. + +## Async Lazy Imports + +`AsyncSafeguardClient` and `AsyncA2AContext` are lazily imported via +`__getattr__` in `__init__.py`: + +```python +_ASYNC_LAZY_IMPORTS: dict[str, str] = { + "AsyncSafeguardClient": ".async_client", + "AsyncA2AContext": ".async_a2a", +} +``` + +On first access: +- If `aiohttp` is installed → import succeeds transparently +- If `aiohttp` is missing → raises: + ``` + ImportError: AsyncSafeguardClient requires the 'async' extra. + Install it with: pip install pysafeguard[async] + ``` + +This means `import pysafeguard` always works, even without aiohttp installed. +The async classes only fail when actually accessed. + +## Sync/Async Parity + +`AsyncSafeguardClient` mirrors `SafeguardClient` method-for-method: + +- Same constructor signature +- Same HTTP methods (`get`, `post`, `put`, `delete`, `request`, `stream`, + `download`, `upload`) +- Same token lifecycle (`login`, `logout`, `refresh_access_token`, + `token_lifetime_remaining`) + +**Convention:** New I/O features added to `SafeguardClient` should always +have async counterparts in `AsyncSafeguardClient`. Same applies to +`A2AContext` / `AsyncA2AContext`. + +## Updating the Public API Surface + +All public exports are defined in `__init__.py`'s `__all__` list. When adding +a new public class or type: + +1. Import it in the top-level imports section of `__init__.py` +2. Add it to `__all__` in the appropriate category (clients, auth, errors, + enums, A2A, events, types) +3. If it's an async class that depends on `aiohttp`, add it to + `_ASYNC_LAZY_IMPORTS` instead of importing directly + +## Design Principles in Practice + +1. **Side-effect-free constructors** — `__init__` stores parameters only. + No HTTP calls, no file I/O, no validation that contacts external services. + +2. **Auth as strategy objects** — Auth logic lives in `Auth` implementations, + not in the client. The client calls `auth.authenticate(self)` and doesn't + know the grant type. + +3. **Keyword-only args** — After the first 1-2 positional params (`service`, + `endpoint`), everything is keyword-only (`*`). + +4. **No mutable defaults** — Always `None` as default, never `{}` or `[]`. + +5. **Explicit `json`/`data` split** — No magic body inference. Caller chooses. + +6. **Clean `__all__`** — Every public symbol is intentionally exported and + typed. No star imports. + +7. **Secret protection** — All credential fields use `HiddenString` with + `repr=False` equivalent behavior. Secrets never appear in `repr()`, + `str()`, or casual logging. + +## URL Assembly Helpers + +`utility.py` provides: + +- `assemble_path(*args)` — joins non-None path segments with `/` +- `assemble_url(netloc, path, query, fragment, scheme)` — builds full URL + via `urlunparse` + `urlencode` +- `get_access_token(data)` — extracts `access_token` from rSTS response dict +- `get_user_token(data)` — extracts `UserToken` from login response dict + +## Python Compatibility + +- **StrEnum shim** (`data_types.py`): Provides `StrEnum` for Python 3.10 + (before `enum.StrEnum` was added in 3.11). +- **LiteralString** (`utility.py`): Conditionally imported from + `typing_extensions` on Python < 3.11. diff --git a/.agents/skills/testing-guide/SKILL.md b/.agents/skills/testing-guide/SKILL.md new file mode 100644 index 0000000..3241f1a --- /dev/null +++ b/.agents/skills/testing-guide/SKILL.md @@ -0,0 +1,197 @@ +--- +name: testing-guide +description: >- + Use when running tests, writing tests, investigating test failures, + or setting up a test environment against a live Safeguard appliance. + Covers pytest configuration, pytest-asyncio, integration test setup, + environment variables, and the preflight fixture. +--- + +# Testing Guide + +## Running Tests + +```bash +# Unit tests (no live appliance required) +python -m pytest tests/ -m "not integration" + +# Integration tests (requires live appliance) +SPP_HOST= SPP_USERNAME= SPP_PASSWORD= python -m pytest tests/ -m integration + +# Single test file +python -m pytest tests/test_auth.py -v + +# Single test by name +python -m pytest tests/ -k "test_password_auth_defaults" -v +``` + +## pytest Configuration + +Configured in `pyproject.toml`: + +```toml +[tool.pytest.ini_options] +asyncio_mode = "auto" +markers = ["integration: requires a live Safeguard appliance"] +testpaths = ["tests"] +``` + +**Key detail:** `asyncio_mode = "auto"` means async test functions run +automatically — you do **not** need `@pytest.mark.asyncio` on every async test. + +## Unit Test Patterns + +Unit tests live in `tests/` (top level, not `tests/integration/`). They use +mocked HTTP and never contact a live appliance. + +### Conventions + +- **Class-based grouping:** Group related tests in classes + (`TestPasswordAuth`, `TestSafeguardClientInit`). +- **No live I/O:** Use `unittest.mock.patch`, `MagicMock`, and + `unittest.mock.AsyncMock` for HTTP calls. +- **Explicit cleanup:** Call `.close()` on constructed clients in tests that + don't use a context manager. +- **Module docstrings:** Each test file starts with a docstring explaining + what it covers and that no live appliance is needed. + +### Example structure + +```python +"""Tests for PasswordAuth construction and protocol conformance. + +No live appliance required — all HTTP is mocked. +""" +from unittest.mock import MagicMock, patch + +import pytest + +from pysafeguard import PasswordAuth, SafeguardClient + + +class TestPasswordAuth: + def test_defaults(self) -> None: + auth = PasswordAuth("local", "admin", "secret") + assert auth.can_refresh is True + + def test_hidden_string_wrapping(self) -> None: + auth = PasswordAuth("local", "admin", "secret") + assert repr(auth._password) == "HiddenString(***)" +``` + +### Async unit tests + +```python +async def test_async_client_login(self) -> None: + """asyncio_mode=auto handles this — no decorator needed.""" + with patch("pysafeguard.async_client.aiohttp.ClientSession"): + client = AsyncSafeguardClient("host", auth=mock_auth) + # ... +``` + +## Integration Tests + +Integration tests live in `tests/integration/` and interact with a real +Safeguard appliance. They are **automatically skipped** when `SPP_HOST` is +not set. + +### Environment Variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `SPP_HOST` | **Yes** | — | Appliance hostname or IP. Tests auto-skip when unset. | +| `SPP_USERNAME` | No | `Admin` | Safeguard user for authentication. | +| `SPP_PASSWORD` | **Yes** | — | Password for the Safeguard user. | +| `SPP_CA_FILE` | No | — | Path to CA certificate file. Omit to disable TLS verification. | + +### Auto-skip Mechanism + +In `tests/conftest.py`, `pytest_collection_modifyitems()` adds a skip marker +to any test with the `integration` keyword when `SPP_HOST` is unset. This +means integration tests are silently skipped in CI or local runs without an +appliance. + +### Preflight Fixture: Resource Owner Grant + +`tests/integration/conftest.py` defines an `autouse=True`, session-scoped +fixture `_ensure_resource_owner_grant` that: + +1. Tries `PasswordAuth` login — if it works, ROG is already enabled. +2. If password login fails, uses `PkceAuth` to log in instead. +3. Reads `Service.CORE/Settings` and enables the `"ResourceOwner"` grant type + if missing. +4. **Restores the original setting** after the test session completes. + +This mirrors the behavior in SafeguardDotNet and safeguard-ps test runners. + +### Shared Fixtures + +Defined in `tests/integration/conftest.py`: + +| Fixture | Scope | Description | +|---------|-------|-------------| +| `spp_host` | session | `SPP_HOST` env var | +| `spp_username` | session | `SPP_USERNAME` (default `"Admin"`) | +| `spp_password` | session | `SPP_PASSWORD` env var | +| `spp_verify` | session | `SPP_CA_FILE` path or `False` | +| `sync_connection` | function | Logged-in `SafeguardClient` with `PasswordAuth` | +| `async_connection` | function | Logged-in `AsyncSafeguardClient` with `PasswordAuth` | +| `unique_name` | function | `PySg_<8hex>` prefix for test resource names | + +### Cleanup Helpers + +`delete_user_sync()` and `delete_user_async()` in `tests/integration/conftest.py` +silently ignore 404/not-found errors during cleanup. Use these instead of raw +DELETE calls in teardown. + +### Writing a New Integration Test + +1. Create the file in `tests/integration/` (e.g., `test_my_feature.py`). +2. Add the module-level marker: + ```python + pytestmark = pytest.mark.integration + ``` +3. Use the shared fixtures (`sync_connection`, `async_connection`, etc.). +4. Follow the naming convention: `test__sync.py` / `test__async.py`. + +## Known Gotchas + +### signalrcore Protocol Version Bug + +`signalrcore` 1.0.2 has a bug: it uses the negotiate endpoint's +`negotiateVersion` (which Safeguard returns as `0`) as the SignalR hub protocol +version. This causes a handshake failure: + +> "The server does not support version 0 of the 'json' protocol." + +**Workaround** (already applied in `src/pysafeguard/event.py`): + +```python +from signalrcore.protocol.json_hub_protocol import JsonHubProtocol +protocol = JsonHubProtocol(version=1) +# Pass via hub_connection_builder.with_hub_protocol(protocol) +``` + +If writing event listener tests, be aware this workaround is applied +automatically by `_json_hub_protocol()`. + +### A2A Integration Test Setup + +A2A tests (`tests/integration/test_a2a.py`) require extensive appliance setup: + +1. **Local test admin** with appropriate roles +2. **Trusted self-signed certificate** uploaded to the appliance +3. **Certificate user** linked to the trusted cert +4. **"Other Managed" asset** with an account +5. **A2A registration** with an API key +6. For `set_password` tests: `BidirectionalEnabled=true` on the registration + +The test's session-scoped `a2a_env` fixture handles all of this automatically +(including `openssl` cert generation in a temp directory). Cleanup deletes the +trusted cert by thumbprint. + +### A2A `set_password` Content-Type + +A2A `set_password` requires `Content-Type: application/json`. Use `json=` +parameter, not `data=`. Using `data=` results in a **415 Unsupported Media +Type** error. From 35158da948db130fa79a93f609b29449246275a4 Mon Sep 17 00:00:00 2001 From: petrsnd Date: Fri, 15 May 2026 19:21:39 -0600 Subject: [PATCH 2/3] Reduce AGENTS.md and ref skills --- AGENTS.md | 397 +++++++++++++----------------------------------------- 1 file changed, 92 insertions(+), 305 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6070676..31871c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,190 +1,92 @@ # AGENTS.md -- PySafeguard -Python SDK for the One Identity Safeguard Web API. Published as a package on -[PyPI](https://pypi.org/project/pysafeguard/). +Python SDK for the One Identity Safeguard Web API. Published on +[PyPI](https://pypi.org/project/pysafeguard/). Requires Python ≥ 3.10. -Requires Python ≥ 3.10. Dependencies: requests, truststore (and -typing\_extensions on Python < 3.11). Optional extras: `async` (aiohttp), -`signalr` (signalrcore). - -The .NET counterpart of this SDK is -[SafeguardDotNet](https://github.com/OneIdentity/SafeguardDotNet). Refer to -SafeguardDotNet's codebase and AGENTS.md for guidance on Safeguard API concepts -and authentication flows. +Dependencies: `requests`, `truststore` (and `typing_extensions` on Python +< 3.11). Optional extras: `async` (aiohttp), `signalr` (signalrcore). ## Project structure ``` PySafeguard/ -|-- src/pysafeguard/ # SDK package -| |-- __init__.py # Public API with __all__ and async lazy imports -| |-- client.py # SafeguardClient (sync, requests-based) -| |-- async_client.py # AsyncSafeguardClient (async, aiohttp-based) -| |-- auth.py # Auth protocol + PasswordAuth, CertificateAuth, PkceAuth, TokenAuth -| |-- errors.py # SafeguardError hierarchy (ApiError, AuthenticationError, etc.) -| |-- data_types.py # Enums: Service, HttpMethod, A2AType, SshKeyFormat -| |-- event.py # SafeguardEventListener, PersistentSafeguardEventListener -| |-- a2a.py # A2AContext (sync) -| |-- async_a2a.py # AsyncA2AContext (async) -| |-- hidden_string.py # HiddenString wrapper for sensitive values -| |-- pkce.py # Sync PKCE non-interactive login (internal) -| |-- async_pkce.py # Async PKCE non-interactive login (internal) -| |-- utility.py # URL assembly, token extraction helpers -| `-- py.typed # PEP 561 marker for typed package -| -|-- tests/ # Unit and integration tests -| |-- conftest.py # Shared fixtures, auto-skip integration when SPP_HOST unset -| |-- test_auth.py # Auth strategy tests -| |-- test_client_new.py # SafeguardClient init/lifecycle tests -| |-- test_client_request_logic.py # SafeguardClient request tests (mocked HTTP) -| |-- test_async_client_new.py # AsyncSafeguardClient tests -| |-- test_safeguard_errors.py # Error hierarchy tests -| |-- test_public_api.py # Export surface verification -| |-- test_event.py # Event listener tests -| |-- test_a2a.py # A2AContext tests -| |-- test_hidden_string.py # HiddenString tests -| |-- test_data_types.py # Enum tests -| |-- test_pkce_helpers.py # PKCE flow tests -| |-- test_utility.py # Utility function tests -| `-- integration/ # Live-appliance integration tests -| |-- conftest.py # Preflight fixture: enables ROG via PKCE if disabled -| |-- test_auth_sync.py # Sync auth flow tests (password, cert, PKCE) -| |-- test_auth_async.py # Async auth flow tests -| |-- test_invoke_sync.py # Sync API invocation tests -| |-- test_invoke_async.py # Async API invocation tests -| |-- test_user_crud_sync.py # Sync user CRUD operations -| |-- test_user_crud_async.py # Async user CRUD operations -| |-- test_token_sync.py # Sync token lifecycle tests -| |-- test_token_async.py # Async token lifecycle tests -| |-- test_client_features.py # Client feature tests (provider lookup, etc.) -| |-- test_streaming.py # Stream/download/upload tests -| |-- test_a2a.py # A2A credential retrieval tests -| |-- test_event_listener.py # SignalR event listener tests -| |-- test_persistent_listener_factory.py # Persistent listener factory tests -| |-- test_certificate_auth.py # Certificate auth-specific tests -| |-- test_pkce_auth.py # PKCE auth-specific tests -| |-- test_factories.py # Factory method tests -| `-- test_anonymous.py # Anonymous/unauthenticated access tests -| -|-- samples/ # Example scripts (see Samples section below) -| |-- PasswordExample.py # PasswordAuth with local provider -| |-- PasswordExternalExample.py # PasswordAuth with external provider -| |-- CertificateExample.py # CertificateAuth -| |-- CertificateExternalExample.py # CertificateAuth with external provider -| |-- PkceExample.py # PkceAuth flow -| |-- AnonymousExample.py # Unauthenticated access (Service.NOTIFICATION) -| |-- NewUserExample.py # User creation via CORE API -| |-- SignalRExample.py # One-shot SignalR event listener -| |-- PersistentSignalRExample.py # Auto-reconnecting event listener -| |-- A2APasswordExample.py # A2A password retrieval -| |-- A2APrivateKeyExample.py # A2A private key retrieval -| `-- A2AApiKeySecretExample.py # A2A API key secret retrieval -| -|-- pipeline-templates/ # Azure Pipelines shared templates -| |-- build-steps.yml # Build, lint, test, package steps -| `-- global-variables.yml # Pipeline variable definitions -|-- pyproject.toml # Project metadata, dependencies (Poetry build backend) -|-- ruff.toml # Ruff linter/formatter configuration -|-- mypy.ini # Mypy strict type checking configuration -|-- azure-pipelines.yml # CI/CD: build with Poetry, publish to PyPI on tag -|-- versionnumber.ps1 # PowerShell script for CI version stamping -`-- README.md # User-facing documentation and usage examples +|-- src/pysafeguard/ # SDK package +| |-- __init__.py # Public API (__all__) and async lazy imports +| |-- client.py # SafeguardClient (sync, requests-based) +| |-- async_client.py # AsyncSafeguardClient (async, aiohttp-based) +| |-- auth.py # Auth protocol + PasswordAuth, CertificateAuth, PkceAuth, TokenAuth +| |-- errors.py # SafeguardError hierarchy +| |-- data_types.py # Enums: Service, HttpMethod, A2AType, SshKeyFormat +| |-- event.py # SafeguardEventListener, PersistentSafeguardEventListener +| |-- a2a.py / async_a2a.py # A2AContext / AsyncA2AContext +| |-- hidden_string.py # HiddenString (sensitive value wrapper) +| |-- pkce.py / async_pkce.py # PKCE non-interactive login (internal) +| `-- utility.py # URL assembly, token extraction helpers +|-- tests/ # Unit tests (mocked HTTP, no appliance needed) +| `-- integration/ # Live-appliance integration tests +|-- samples/ # Example scripts for each auth strategy and feature +|-- pipeline-templates/ # Azure Pipelines shared templates +|-- pyproject.toml # Poetry build backend, dependencies, pytest config +|-- ruff.toml # Ruff linter/formatter (line length: 160) +|-- mypy.ini # Mypy strict type checking +`-- azure-pipelines.yml # CI/CD: build, lint, test, publish to PyPI on tag ``` -## Setup and build commands - -The project uses [Poetry](https://python-poetry.org/) as its build backend. +## Setup and build ```bash -# Install Poetry (if not already installed) -pip install poetry - -# Install all dependencies (including dev and optional extras) -poetry install --all-extras - -# Build the distribution (sdist + wheel) -poetry build +pip install poetry # Install Poetry (if needed) +poetry install --all-extras # Install all deps including dev and optional +poetry build # Build sdist + wheel ``` ## Linting and type checking ```bash -# Lint with ruff (line length: 160) -ruff check src/ - -# Format check with ruff -ruff format --check src/ - -# Type check with mypy (strict mode enabled) -mypy src/ +ruff check src/ # Lint (line length: 160) +ruff format --check src/ # Format check +mypy src/ # Type check (strict mode) ``` -Mypy is configured in strict mode with all strict flags enabled. All code must -pass `mypy --strict` without errors. - -Ruff enforces a line length of 160 characters. +All code must pass `mypy --strict` without errors. Ruff enforces 160-char lines. ## Testing ```bash -# Run unit tests (no live appliance required) -python -m pytest tests/ -m "not integration" - -# Run integration tests (requires live appliance) -SPP_HOST= SPP_USERNAME= SPP_PASSWORD= python -m pytest tests/ -m integration +python -m pytest tests/ -m "not integration" # Unit tests (no appliance) +python -m pytest tests/ -m integration # Integration tests (live appliance) ``` -The test suite uses `pytest-asyncio` with `asyncio_mode = "auto"` (configured -in `pyproject.toml`), so async test functions run automatically without -`@pytest.mark.asyncio`. - -### Testing against a live appliance - -Integration tests interact with a real Safeguard appliance. **If making -non-trivial changes to authentication, API calls, or event handling, ask the -user for appliance access** and request: appliance address, admin username, -admin password, and CA certificate path (or `False` to disable TLS verification). - -Environment variables for integration tests: +Uses `pytest-asyncio` with `asyncio_mode = "auto"` — async tests run without +`@pytest.mark.asyncio`. Integration tests auto-skip when `SPP_HOST` is unset. -| Variable | Required | Default | Description | -|---|---|---|---| -| `SPP_HOST` | **Yes** | — | Appliance hostname or IP. Tests auto-skip when unset. | -| `SPP_USERNAME` | No | `Admin` | Safeguard user for authentication. | -| `SPP_PASSWORD` | **Yes** | — | Password for the Safeguard user. | -| `SPP_CA_FILE` | No | — | Path to CA certificate file. Omit to disable TLS verification. | +**For non-trivial auth/API/event changes, ask the user for appliance access.** +See the `testing-guide` skill for environment variables, fixtures, and patterns. ## Architecture -### Client classes - -| Class | Module | HTTP library | Description | -|---|---|---|---| -| `SafeguardClient` | `client.py` | `requests` | Primary sync client. Side-effect-free constructor. | -| `AsyncSafeguardClient` | `async_client.py` | `aiohttp` | Async mirror of SafeguardClient. | - -### Authentication strategies +| Class | Module | Description | +|---|---|---| +| `SafeguardClient` | `client.py` | Primary sync client (`requests`) | +| `AsyncSafeguardClient` | `async_client.py` | Async mirror (`aiohttp`) | -Auth objects are passed to the client constructor. Each implements the `Auth` -protocol (`auth.py`) with `authenticate()`, `refresh()`, `can_refresh`, and -async variants. The `Auth` protocol itself is exported in `__all__` and can be -used for type annotations. +### Auth strategies -| Strategy | Module | Description | -|---|---|---| -| `PasswordAuth` | `auth.py` | Username/password (Resource Owner Grant) | -| `CertificateAuth` | `auth.py` | Client certificate authentication | -| `PkceAuth` | `auth.py` | PKCE non-interactive browser flow (recommended) | -| `TokenAuth` | `auth.py` | Pre-existing bearer token (no refresh) | +Auth objects implement the `Auth` protocol (`auth.py`) and are passed to the +client constructor. Secret fields are auto-wrapped in `HiddenString`. -Secret fields (passwords, tokens) are auto-wrapped in `HiddenString`. +| Strategy | Description | +|---|---| +| `PasswordAuth` | Username/password (Resource Owner Grant) | +| `CertificateAuth` | Client certificate authentication | +| `PkceAuth` | PKCE non-interactive flow (recommended for newer appliances) | +| `TokenAuth` | Pre-existing bearer token (no refresh) | ### Usage pattern ```python from pysafeguard import SafeguardClient, PasswordAuth, Service -# Context manager auto-logs in and out with SafeguardClient("appliance.example.com", auth=PasswordAuth("local", "admin", "secret"), verify=False) as client: @@ -192,181 +94,66 @@ with SafeguardClient("appliance.example.com", client.post(Service.CORE, "Users", json={"Name": "NewUser"}) ``` -### Authentication flow - -1. Construct `SafeguardClient` with an `Auth` object (no network I/O) -2. Call `client.login()` (or use context manager which calls it automatically) -3. Auth object POSTs to `RSTS/oauth2/token` for an rSTS access token -4. rSTS access token is exchanged for a Safeguard `UserToken` via `Core/Token/LoginResponse` -5. `UserToken` is sent as `Authorization: Bearer ` on subsequent requests -6. `client.logout()` invalidates the token on the appliance - -### HTTP methods - -```python -client.get(service, endpoint, *, params=None, headers=None, host=None, cert=None, api_version=None) -client.post(service, endpoint, *, json=None, data=None, params=None, headers=None, host=None, cert=None, api_version=None) -client.put(service, endpoint, *, json=None, data=None, params=None, headers=None, host=None, cert=None, api_version=None) -client.delete(service, endpoint, *, params=None, headers=None, host=None, cert=None, api_version=None) -client.request(method, service, endpoint, *, ...) # Low-level escape hatch -``` - -- `json=` for dict/list bodies (auto-sets content-type) -- `data=` for raw string bodies -- `params=` for query parameters -- `headers=` for additional headers (merged with defaults) -- `host=` to override the target host (useful for clusters) -- `cert=` for client certificate as `(cert_file, key_file)` tuple -- `api_version=` to override the API version for a single request - -### Streaming - -```python -client.stream(method, service, endpoint, **kwargs) # Returns un-consumed response -client.download(service, endpoint, file_path, **kwargs) # Stream to file -client.upload(service, endpoint, file_or_stream, **kwargs) # Upload file/bytes -``` - -### Safeguard API services +### API services | Enum | URL path | Description | |---|---|---| -| `Service.CORE` | `service/core` | Primary API: assets, users, policies, access requests | -| `Service.APPLIANCE` | `service/appliance` | Appliance management: networking, diagnostics, backups | -| `Service.NOTIFICATION` | `service/notification` | Anonymous status and notification endpoints | -| `Service.A2A` | `service/a2a` | Application-to-Application credential retrieval | +| `Service.CORE` | `service/core` | Users, assets, policies, access requests | +| `Service.APPLIANCE` | `service/appliance` | Appliance management | +| `Service.NOTIFICATION` | `service/notification` | Anonymous status endpoints | +| `Service.A2A` | `service/a2a` | Application-to-Application credentials | | `Service.EVENT` | `service/event` | SignalR event streaming | -| `Service.RSTS` | `RSTS` | Embedded secure token service (authentication) | +| `Service.RSTS` | `RSTS` | Embedded secure token service | -The default API version is **v4**. +Default API version: **v4**. ### Error hierarchy ``` -SafeguardError (base) -├── ApiError (HTTP error responses) +SafeguardError +├── ApiError (HTTP errors; auto-mapped by status code) │ ├── AuthenticationError (401) -│ ├── AuthorizationError (403) -│ └── NotFoundError (404) -└── TransportError (network/connection failures) -``` - -`ApiError.from_response(resp)` auto-maps status codes to subclasses. -All errors carry `status_code`, `error_code`, `error_message`, `response_body`. - -### A2A (Application-to-Application) - -`A2AContext` and `AsyncA2AContext` use client certificate authentication with -an API key header (`Authorization: A2A `) to retrieve credentials. -Supports password, private key, and API key secret retrieval. - -For one-shot operations without a context manager, use the static convenience -methods: - -```python -password = A2AContext.quick_retrieve_password(host, api_key, cert_file, key_file) -private_key = A2AContext.quick_retrieve_private_key(host, api_key, cert_file, key_file) +│ ├── AuthorizationError (403) +│ └── NotFoundError (404) +└── TransportError (network/connection failures) ``` -### Event listeners - -- `SafeguardEventListener` — one-shot SignalR listener with token auth -- `PersistentSafeguardEventListener` — auto-reconnecting listener that - re-authenticates on disconnect - -Created via `client.get_event_listener()` / `client.get_persistent_event_listener()`. - -Related public types (all exported from `__init__.py`): - -- `EventHandlerRegistry` — container for registered event handlers -- `EventListenerState` — enum: `STARTING`, `CONNECTED`, `DISCONNECTED`, - `RECONNECTING`, `STOPPED` -- `SafeguardEventHandler` — callback type alias: `(event_name, event_body) -> None` -- `SafeguardStateCallback` — callback type alias: `(EventListenerState) -> None` - -### PKCE non-interactive login - -`pkce.py` (internal) implements the full rSTS PKCE authentication flow without -a browser. This is the **recommended** method on newer appliances where Resource -Owner Grant (ROG) is disabled by default. The flow is exposed to users via `PkceAuth`. - -### HiddenString - -`HiddenString` wraps sensitive values to prevent casual exposure in logs, repr, -and debugger output. Uses `bytearray` storage for explicit zeroing on disposal. -Supports context manager (`with HiddenString(...) as s:`) for scoped disposal, -`__len__`, `__eq__`, `__bool__`, and blocks pickling/copying. - -### Token refresh and lifecycle - -- `client.refresh_access_token()` re-authenticates using the stored auth object -- `client.token_lifetime_remaining` (property) checks remaining token lifetime -- `auto_refresh=True` on construction enables automatic refresh before each request -- `client.logout()` POSTs to `Token/Logout` then clears the local token - -### Async lazy imports - -`AsyncSafeguardClient` and `AsyncA2AContext` are lazily imported via -`__getattr__` in `__init__.py` so that `import pysafeguard` works without the -`[async]` extra (aiohttp) installed. On first access, if aiohttp is missing, a -helpful `ImportError` is raised directing the user to install -`pysafeguard[async]`. - ## Code conventions -### Type annotations - -All functions must have complete type annotations. The project uses `mypy` in -strict mode. Use `typing.cast()` when narrowing types from JSON responses. - -`data_types.py` includes a `StrEnum` compatibility shim for Python 3.10 (before -`enum.StrEnum` was added in 3.11). Similarly, `utility.py` conditionally imports -`LiteralString` from `typing_extensions` on Python < 3.11. - -### Naming conventions - -- Classes: PascalCase (`SafeguardClient`, `PasswordAuth`) -- Methods/functions: snake_case (`get_provider_id`, `assemble_url`) -- Instance attributes: snake_case (`user_token`, `api_version`, `is_authenticated`) -- Enums: Singular names with UPPER_CASE values (`Service.CORE`, `HttpMethod.GET`) -- Private attributes: single underscore prefix (`_user_token`, `_session`) - -### Docstrings - -Use reStructuredText-style docstrings (`:param name:`, `:returns:`). Every -public method should have a docstring. - -### Design principles - -1. **Side-effect-free constructors** — no network I/O in `__init__` -2. **Auth as strategy objects** — not factory functions or instance methods -3. **Keyword-only args** — after the first 1-2 positional params -4. **No mutable defaults** — use `None` everywhere -5. **snake_case everything** — no exceptions -6. **Explicit `json`/`data` split** — no magic body inference -7. **Clean `__all__`** — typed, intentional public surface -8. **Secret protection** — HiddenString/repr=False on all credential fields +- **Type annotations:** Complete on all functions. `mypy --strict` enforced. + Use `typing.cast()` for JSON narrowing. `StrEnum` shim in `data_types.py` + for Python 3.10; `LiteralString` from `typing_extensions` on < 3.11. +- **Naming:** Classes PascalCase, methods/attrs snake_case, enums + UPPER_CASE values, private attrs `_prefixed`. +- **Docstrings:** reStructuredText style (`:param name:`, `:returns:`). +- **Design principles:** + 1. Side-effect-free constructors — no network I/O in `__init__` + 2. Auth as strategy objects — not factory functions + 3. Keyword-only args — after the first 1-2 positional params + 4. No mutable defaults — use `None` everywhere + 5. Explicit `json`/`data` split — no magic body inference + 6. Clean `__all__` — typed, intentional public surface + 7. Secret protection — `HiddenString` on all credential fields ## Deprecations (v8.0) -The following are deprecated and will be removed in a future version: - -- **Plural enum aliases** (`data_types.py`): `Services` → use `Service`, - `HttpMethods` → use `HttpMethod`, `A2ATypes` → use `A2AType`, - `SshKeyFormats` → use `SshKeyFormat`. -- **`HiddenString.get_value()`** (`hidden_string.py`): Use the `.value` - property instead. +- **Plural enum aliases:** `Services` → `Service`, `HttpMethods` → `HttpMethod`, + `A2ATypes` → `A2AType`, `SshKeyFormats` → `SshKeyFormat`. +- **`HiddenString.get_value()`** → Use `.value` property instead. ## Versioning and release -The version is set in `pyproject.toml` (`version = "X.Y.Z"`). The CI pipeline -(`azure-pipelines.yml`) delegates version stamping to -`pipeline-templates/build-steps.yml`, which calls the PowerShell script -`versionnumber.ps1`. This script computes the package version from the Git tag -name and build ID. +Version is in `pyproject.toml` but **do not edit manually** — CI stamps from +Git tag via `versionnumber.ps1`. Releases publish to PyPI automatically when a +tag is pushed (`twine` via `pypiOneIdentity` service connection). + +## On-demand skills -Releases are published to PyPI automatically when a Git tag is pushed. The -pipeline uses `twine` to upload via the `pypiOneIdentity` service connection. +The following skills provide deeper reference material. Read the `SKILL.md` +when your current task matches the trigger. -**Do not change the version in `pyproject.toml` manually for releases.** The -CI pipeline handles version stamping from the Git tag. +| Skill | When to read | File | +|-------|-------------|------| +| Testing Guide | Running/writing tests, test failures, live appliance setup | `.agents/skills/testing-guide/SKILL.md` | +| API Patterns | HTTP methods, streaming, errors, A2A, token lifecycle | `.agents/skills/api-patterns/SKILL.md` | +| Architecture | Module internals, auth flow, events, PKCE, HiddenString | `.agents/skills/architecture/SKILL.md` | From c0513e2b0d35ca9edd9dabe17a341069cb5d6797 Mon Sep 17 00:00:00 2001 From: petrsnd Date: Fri, 15 May 2026 19:27:29 -0600 Subject: [PATCH 3/3] Reduce AGENTS.md and ref skills --- .agents/skills/api-patterns/SKILL.md | 25 +++++++++++++++++++++++++ .agents/skills/architecture/SKILL.md | 14 ++++++++++++++ .agents/skills/testing-guide/SKILL.md | 21 +++++++++++++++++---- AGENTS.md | 19 +++++++++++-------- 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/.agents/skills/api-patterns/SKILL.md b/.agents/skills/api-patterns/SKILL.md index b6bc68d..f208d60 100644 --- a/.agents/skills/api-patterns/SKILL.md +++ b/.agents/skills/api-patterns/SKILL.md @@ -309,6 +309,31 @@ A2A requests use `Authorization: A2A ` (not Bearer). the raw API yourself, you **must** use `Content-Type: application/json`. Using `data=` (raw string) results in **415 Unsupported Media Type**. +## TLS / Certificate Verification + +The `verify` parameter on `SafeguardClient` and `A2AContext` accepts: + +- `True` (default) — use system trust store +- `False` — disable TLS verification (development only) +- `str` — path to a CA bundle file for custom trust + +```python +# CA bundle (recommended for production) +client = SafeguardClient("host", auth=auth, verify="/path/to/ca-bundle.pem") + +# Disable verification (development only) +client = SafeguardClient("host", auth=auth, verify=False) +``` + +### Environment Variables for Trust + +| Variable | Affects | Description | +|----------|---------|-------------| +| `REQUESTS_CA_BUNDLE` | All HTTP requests | CA bundle path for `requests` library | +| `WEBSOCKET_CLIENT_CA_BUNDLE` | SignalR event listeners | CA bundle path for WebSocket connections | + +Set these when the appliance uses a certificate signed by an internal CA. + ## Common Patterns ### GET with query parameters diff --git a/.agents/skills/architecture/SKILL.md b/.agents/skills/architecture/SKILL.md index 4291f79..df27059 100644 --- a/.agents/skills/architecture/SKILL.md +++ b/.agents/skills/architecture/SKILL.md @@ -207,6 +207,20 @@ listener.start() - Previous client is logged out best-effort on each reconnection - `stop()` stops inner listener, joins reconnect thread, emits `STOPPED` +### Client Factory Methods + +`SafeguardClient` provides convenience methods to create event listeners +from an authenticated client: + +```python +listener = client.get_event_listener() # SafeguardEventListener +persistent = client.get_persistent_event_listener() # PersistentSafeguardEventListener +``` + +**Note:** `AsyncSafeguardClient` does **not** have event listener factory +methods. Create listeners directly using the sync client or construct them +manually. + ### signalrcore Protocol Version Bug signalrcore 1.0.2 incorrectly uses `negotiateVersion` (the negotiate diff --git a/.agents/skills/testing-guide/SKILL.md b/.agents/skills/testing-guide/SKILL.md index 3241f1a..b7c7368 100644 --- a/.agents/skills/testing-guide/SKILL.md +++ b/.agents/skills/testing-guide/SKILL.md @@ -13,16 +13,16 @@ description: >- ```bash # Unit tests (no live appliance required) -python -m pytest tests/ -m "not integration" +poetry run python -m pytest tests/ -m "not integration" # Integration tests (requires live appliance) -SPP_HOST= SPP_USERNAME= SPP_PASSWORD= python -m pytest tests/ -m integration +SPP_HOST= SPP_USERNAME= SPP_PASSWORD= poetry run python -m pytest tests/ -m integration # Single test file -python -m pytest tests/test_auth.py -v +poetry run python -m pytest tests/test_auth.py -v # Single test by name -python -m pytest tests/ -k "test_password_auth_defaults" -v +poetry run python -m pytest tests/ -k "test_password_auth_defaults" -v ``` ## pytest Configuration @@ -190,6 +190,19 @@ The test's session-scoped `a2a_env` fixture handles all of this automatically (including `openssl` cert generation in a temp directory). Cleanup deletes the trusted cert by thumbprint. +### TLS Trust for Integration Tests + +If the appliance uses a certificate signed by an internal CA, set these +environment variables in addition to the standard test variables: + +| Variable | Affects | Description | +|----------|---------|-------------| +| `REQUESTS_CA_BUNDLE` | All HTTP requests | CA bundle path for `requests` | +| `WEBSOCKET_CLIENT_CA_BUNDLE` | SignalR event listeners | CA bundle path for WebSocket | + +Alternatively, pass the CA path via `SPP_CA_FILE` — the test fixtures pass it +as `verify=` to client constructors. + ### A2A `set_password` Content-Type A2A `set_password` requires `Content-Type: application/json`. Use `json=` diff --git a/AGENTS.md b/AGENTS.md index 31871c0..a405b3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,17 +35,20 @@ PySafeguard/ ## Setup and build ```bash -pip install poetry # Install Poetry (if needed) -poetry install --all-extras # Install all deps including dev and optional -poetry build # Build sdist + wheel +pip install poetry # Install Poetry (if needed) +poetry install --all-extras # Install all deps including dev and optional +poetry build # Build sdist + wheel ``` +All `poetry run` prefixed commands below assume deps are installed via +`poetry install --all-extras`. + ## Linting and type checking ```bash -ruff check src/ # Lint (line length: 160) -ruff format --check src/ # Format check -mypy src/ # Type check (strict mode) +poetry run ruff check src/ # Lint (line length: 160) +poetry run ruff format --check src/ # Format check +poetry run mypy src/ # Type check (strict mode) ``` All code must pass `mypy --strict` without errors. Ruff enforces 160-char lines. @@ -53,8 +56,8 @@ All code must pass `mypy --strict` without errors. Ruff enforces 160-char lines. ## Testing ```bash -python -m pytest tests/ -m "not integration" # Unit tests (no appliance) -python -m pytest tests/ -m integration # Integration tests (live appliance) +poetry run python -m pytest tests/ -m "not integration" # Unit tests +poetry run python -m pytest tests/ -m integration # Integration (live appliance) ``` Uses `pytest-asyncio` with `asyncio_mode = "auto"` — async tests run without