Metadata-Version: 2.5
Name: hawcx-haap
Version: 0.1.10
Summary: Customer SDK for the Hawcx Agent Authentication Protocol (HAAP). Connects to a customer-deployed haap-supervisor and proxies tool calls through the Assembler per CS v7.2.5 Profile E.
Project-URL: Homepage, https://github.com/hawcx/hawcx_agentic_sdk
Project-URL: Repository, https://github.com/hawcx/hawcx_agentic_sdk
Project-URL: Issues, https://github.com/hawcx/hawcx_agentic_sdk/issues
Author: Hawcx Inc.
License: Hawcx Proprietary License
Keywords: agent,authentication,haap,hawcx,mcp,tbac
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Provides-Extra: cli
Requires-Dist: pyyaml>=6.0; extra == 'cli'
Provides-Extra: crewai
Requires-Dist: crewai>=0.51; extra == 'crewai'
Provides-Extra: dev
Requires-Dist: anthropic>=1.7; extra == 'dev'
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: cryptography>=41; extra == 'dev'
Requires-Dist: httpx2>=2.0.0; extra == 'dev'
Requires-Dist: httpx>=0.27.0; extra == 'dev'
Requires-Dist: hypothesis>=6; extra == 'dev'
Requires-Dist: mypy>=1.5; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: requests>=2.31; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Requires-Dist: wheel>=0.43; extra == 'dev'
Provides-Extra: httpx
Requires-Dist: httpx>=0.27.0; extra == 'httpx'
Provides-Extra: httpx2
Requires-Dist: httpx2>=2.0.0; extra == 'httpx2'
Provides-Extra: requests
Requires-Dist: requests>=2.31; extra == 'requests'
Requires-Dist: urllib3>=2; extra == 'requests'
Description-Content-Type: text/markdown

# hawcx-haap

Customer SDK for the **Hawcx Agent Authentication Protocol** (HAAP Canonical
Specification v7.2.0, Profile E). Pure-Python, no native build.

> **Status:** alpha (0.1.0a1). Public API may change. End-to-end testing
> against the real binary pipeline is pending alpha-2 closure of the RSV
> cascade adapter; the SDK is currently validated against a mock Assembler.

## What it does

`HawcxAgent` connects to a customer-deployed `haap-supervisor`'s
Assembler-agent socket and proxies Profile E tool calls. The supervisor and
its child processes are installed separately (via the `hx_agentic_sdk` release
tarball or Docker image); this SDK is just the language-side client.

Per CS §39, all cryptographic operations happen in the Assembler / TQS /
Authenticator processes. The Python process never holds session keys or token
material — process isolation is enforced by OS boundaries (Unix Domain Sockets
on Linux/macOS, Named Pipes with DACL on Windows per CS §39.12).

## Install

```bash
pip install hawcx-haap
```

Single pure-Python wheel; supports Python 3.10–3.13 on Linux, macOS, and
Windows.

## Prerequisites

- The `haap-supervisor` pipeline (Authenticator + TQS-precompute + TQS-jit +
  Assembler + External Identity Broker + Supervisor — per HAAP CS v7.2.0
  §45.2) must be running locally, installed from the `hx_agentic_sdk`
  release.
- The agent identity must be pre-provisioned via the Hawcx Admin Console
  (Console → CAA → Authenticator flow per CS §4.6.3).

## Quickstart

```python
from hawcx_haap import HawcxAgent

with HawcxAgent.connect("/var/run/haap/research-u1/agent-assembler-0.sock") as agent:
    response = agent.invoke(
        target_rs_url="https://api.example.com/search",
        http_method="POST",
        headers={"Content-Type": "application/json"},
        tool="search",
        action=["read"],
        body=b'{"query": "agents"}',
    )
    print(response.http_status, response.body[:200])
```

If you want the SDK to derive the socket path from an agent id:

```python
with HawcxAgent.connect_by_agent_id("research-u1") as agent:
    ...
```

This uses the conventional path
`{XDG_RUNTIME_DIR or /tmp}/hawcx/{agent_id}/agent-assembler-0.sock` on Unix
and `\\.\pipe\haap-{agent_id}-agent-assembler-0` on Windows.

## API

### `HawcxAgent.connect(endpoint, *, timeout_secs=5.0) -> HawcxAgent`

Open the agent IPC socket at `endpoint` and complete the version handshake.

### `HawcxAgent.connect_by_agent_id(agent_id, *, index=0, ipc_dir=None, timeout_secs=5.0)`

Resolve the conventional path, then `connect`.

### `.invoke(...) -> ToolCallResponse`

| Argument | Type | Notes |
|---|---|---|
| `target_rs_url` | `str` | RS endpoint URL (required) |
| `http_method` | `str` | Default `"POST"` |
| `headers` | `dict[str, str] \| None` | Extra HTTP headers |
| `tool` | `str` | Tool / endpoint identifier |
| `action` | `Iterable[str] \| None` | Permitted operations (CS §39.7) |
| `resource` | `str` | Default `"*"` |
| `constraints` | `dict \| None` | TBAC constraints |
| `body` | `bytes \| None` | Request body (maps to `plaintext_request_body`) |
| `claimed_intent_hash` | `str \| None` | For §39.4 intent verification |
| `tool_arguments` | `Any` | Structured arguments |
| `content_type` | `str \| None` | Request content type |
| `transport` | `TokenTransport \| None` | `HTTP_HEADER` (default) or `MCP_META` |
| `request_id` | `str \| None` | Defaults to `req-<uuid4-hex16>` |

Returns `ToolCallResponse(request_id, http_status, headers, body)`. The `body`
field is the decrypted RS response (`bytes`).

Raises `RequestRejected(request_id, reason)` if the Assembler rejects.

### `TokenTransport`

```python
class TokenTransport(str, Enum):
    HTTP_HEADER = "http_header"   # Authorization: HAAP <b64>
    MCP_META = "mcp_meta"         # MCP params._meta["haap/tbac"].token
```

Per CS v7.2.0 §34. Default per-call selector is omitted on the wire → the
Assembler uses `HttpHeader`.

## Calling an MCP server — `hawcx_haap.mcp_caller`

`invoke()` is the transport. `mcp_caller` is the layer above it for agents
whose destination is an MCP server: it builds the JSON-RPC 2.0 `tools/call`
document, routes it through `invoke()`, and — the part worth not rewriting per
agent — decides whether the answer was an allow or a deny.

```python
from hawcx_haap import Caller, McpTool, close_agent, get_agent

MAILBOX = McpTool(
    tool_id="mail.read",                             # the scope policy is written against
    url="https://mcp.example.com/servers/mail",
    name="list_messages",                            # the downstream MCP tool name
    actions=("read",),
    resource="mailbox",
    arguments={"top": 5},
)

caller = Caller(agent=get_agent(["alice@example.com"]), provider="microsoft")
try:
    decision = caller.call(MAILBOX, "alice@example.com")
    print(decision.summary())   # "ALLOW mail.read  as alice@example.com http=200"
finally:
    close_agent()
```

`Decision` is `(tool, principal, allowed, reason, reason_code, http_status,
body, request_id)`. A refusal is an outcome, not an exception — nothing here
needs a `try` around it.

**Why classification is in the SDK.** A HAAP denial arrives in three shapes and
only the first looks like a failure:

| Shape | Where it was refused | What a naive reader sees |
|---|---|---|
| `RequestRejected` (0x54) | token mint, before egress | an exception — hard to miss |
| JSON-RPC `error.code` in `-32005…-32000` (§45.7.5) | RSV MCP gateway | **HTTP 200** |
| the same error inside an SSE frame | RSV MCP gateway | **HTTP 200**, and a body that is not JSON |

The third shape is the trap. Streamable HTTP lets the server answer with a JSON
body *or* an event stream, and a real SSE frame opens with `event: message` —
so sniffing for a leading `data:` decides it is not a stream, finds no error,
and reports a denial as an allow. `_json_documents()` tries the whole body as
JSON and then scans every `data:` line unconditionally, and
`tests/test_mcp_caller.py` pins the `event: message` case.

Fail-closed both ways: `principal_allowlist` stays required all the way down,
and a body that yields no JSON-RPC document at all — empty, truncated, HTML,
non-UTF-8 — is a **deny**. Not being able to tell is not the same as being told
yes. A JSON-RPC error *outside* the HAAP range (`-32601` "method not found",
say) is a downstream fault and stays an allow; reporting it as a policy denial
would manufacture evidence of a decision nobody made.

`Caller.invoke_kwargs()` returns the `invoke()` kwargs without calling
anything, so a scope review can print the request that *would* go — feed it to
`ToolCallRequest(plaintext_request_body=body, **kwargs).to_wire()`.

The module is pure-Python and adds no dependency, so a consumer built on it
still bundles with `hawcx bundle`.

## Scaffolding an agent's config — `hawcx init`

`mcp_caller` is the code. `config.py` is the part only your tenant knows: where
each MCP server is, what the downstream tool is called, which resource, which
provider, and which principals the agent may act for. `hawcx init` writes both
halves from one `hawcx/agent-template/v1`:

```bash
hawcx init agent-template.yaml -d ./myagent
```

| File | Owner | Re-running `hawcx init` |
|---|---|---|
| `hawcx_tools.py` | `@generated` — do not edit | rewritten with `--force` |
| `config.py` | **yours** — edit and keep it | **kept**, always; `--force` does not reach it |

One `--force` cannot serve both files, which is why this is `init` rather than
a `wrap --config` flag: a flag you pass to regenerate the module would
eventually eat a config someone had spent a day filling in.

Every deployment-specific value is scaffolded as `FILL_ME`, and the file ends
in `require_filled(...)`. So an untouched config does not run:

```
ValueError: 8 unfilled config value(s): PROVIDER, PRINCIPAL_ALLOWLIST,
TOOLS['o365.mail.read'].url, TOOLS['o365.mail.read'].name, ...
```

Every gap at once, each named by its path. A commented placeholder
(`# TODO: your RS URL`) would have survived review and reached the Assembler as
a real target; this cannot leave the import.

`PRINCIPAL_ALLOWLIST` is emitted with **no default**. It is the fail-closed gate
on `acting_for_user`, so a default would be a default answer to which users the
agent may impersonate. `[]` is a real answer — "forbid runtime principal
switching entirely" — and must be something a human chose, not something the
scaffolder assumed.

Not scaffolded, on purpose: the HTTP method (MCP `tools/call` over Streamable
HTTP is POST, and `Caller.invoke_kwargs` sets it — a knob whose only correct
value is the default is a knob someone turns), and per-tool providers (a
`Caller` carries one `provider` for the destination it talks to; an agent
spanning two providers builds two `Caller`s). The generated config imports only
`hawcx_haap`, so an agent carrying it still bundles with `hawcx bundle`.

## Wire protocol

The SDK speaks the same wire as the in-process Rust crates:

```
[msg_len: u32 BE][msg_type: u8][payload: msg_len-1 bytes]
```

- `0x00` — `IpcHandshake` (binary; see `crates/haap-ipc/src/handshake.rs`)
- `0x52` — `ToolCallRequest` (JSON)
- `0x53` — `ToolCallResponse` (JSON; `body` is base64)
- `0x54` — `RequestRejected` (JSON: `{request_id, reason}`)
- `0x61` — `ClarificationAnswer` (JSON; Profile E first hop)

Reference: `crates/haap-ipc/src/messages/assembler.rs` in `hx_agent_crypto_core`.

## Threat model — runtime principal

`HawcxAgent` supports per-call principal switching via the
``acting_for_user`` field, which the Assembler projects into
``scope_json.user_principal_id`` on the minted token (CS v6.9.0
line 163). This lets one supervisor pipeline serve multiple end-users
without re-enrolling the agent identity per user.

``acting_for_user`` is sensitive: a value that came from an LLM (or
any input the model can influence) MUST NOT be allowed to silently
switch the effective user. As of 0.1.0a2 (H-3 hardening 2026-05-20):

- ``HawcxAgent.connect(endpoint, principal_allowlist=[...])`` is
  required. The allowlist is a closed set of permitted principal IDs
  sourced from operator config.
- ``agent.invoke(acting_for_user=...)`` and ``agent.invoke_for(...)``
  validate against the allowlist before any IPC bytes are written.
  Out-of-list principals raise ``HawcxError`` synchronously with a
  redacted SHA-256 fingerprint instead of echoing the rejected
  principal back in plaintext.
- Pass ``principal_allowlist=[]`` to forbid runtime principal
  switching entirely.

Operator obligations:

1. Source the allowlist from operator-controlled config — never
   derive from LLM output, request bodies, MCP tool arguments, or any
   input a model can influence.
2. If the principal axis spans more than ~100 users, fan out to
   per-user agents rather than one agent with a wide allowlist; the
   Cedar policy on the gateway should still gate per-user access, but
   reducing the SDK-side allowlist closes the blast radius of a
   compromised supervisor.
3. The previous code that accepted ``acting_for_user`` from any
   caller (without an allowlist) is **deprecated**. See
   `../CHANGELOG.md` for the migration recipe.

## Egress transport (optional) — route agent HTTP through the broker

When a HAAP agent runs under the OS sandbox from ADR-0048, its entire
outbound network is pinned to a single per-agent UNIX-domain socket — a
SOCKS5 egress broker — and that socket is its *only* network path. A SOCKS
proxy URL has nowhere to put a filesystem path
(`socks5h:///…/egress-broker.sock` parses to an empty host), so stock SOCKS
transports, which dial `(host, port)`, cannot reach it. This SDK ships a
small transport shim that opens the UDS, performs the SOCKS5 `CONNECT`, and
hands the connected stream to `httpx` for **end-to-end** TLS + HTTP. The
broker never terminates TLS and neither does the shim — your certificate
verification is unchanged, and the broker holds no plaintext.

The HTTP client is an **optional** extra (the SDK core stays zero-dependency).
Two API-identical `httpx` lineages exist — install the one your client needs:

```bash
pip install 'hawcx-haap[httpx2]'    # Anthropic SDK 1.x and other httpx2 clients
pip install 'hawcx-haap[httpx]'     # the original httpx lineage
pip install 'hawcx-haap[requests]'  # requests_session(), for google-auth et al
```

Opt in with one line — `client()` returns a ready-to-use client:

```python
import hawcx_haap.egress as egress

with egress.client() as http:                 # async_client() for asyncio
    r = http.get("https://api.example.com/v1/models")
```

**Which lineage you get.** The Anthropic SDK 1.x requires `httpx2` and rejects
an old-`httpx` client at construction, so `client()` prefers `httpx2` when both
are installed and the object it returns is what `anthropic` expects:

```python
import anthropic, hawcx_haap.egress as egress

client = anthropic.Anthropic(http_client=egress.client())
```

`egress.flavor()` reports which lineage was selected, and `client(flavor="httpx")`
pins the other one for calls that need it — this package deliberately installs no
process-wide alias. Note the two lineages ship *disjoint* exception hierarchies:
an `httpx2` client raises `httpx2.HTTPError`, not `httpx.HTTPError`. The shim's
own `Egress*` errors are unaffected either way.

### `requests`-based clients (Google libraries)

The Google client libraries build on `requests` (google-auth's
`AuthorizedSession`), which the httpx transport cannot carry.
`requests_session()` brokers those under the identical no-fallback contract:

```python
from google.auth.transport.requests import AuthorizedSession
import hawcx_haap.egress as egress

session = AuthorizedSession(creds)
session.mount("https://", egress.requests_session().get_adapter("https://"))
```

The broker socket is discovered from `$HAAP_EGRESS_BROKER_SOCKET`, or from
`$HAAP_AGENT_SOCKET_DIR/$HAAP_AGENT_INSTANCE_ID/egress-broker.sock` (the
paths the supervisor sets), or pass `socket_path=`. If none resolves to an
existing socket, `client()` **raises** rather than falling back to the direct
network — a silent fallback would defeat the control.

Per ADR-0048 the shim always sends the hostname as `ATYP=0x03` (DOMAINNAME)
and never resolves DNS itself, so the broker enforces its allowlist against
the name the agent actually asked for. Failures map to distinguishable
exceptions (all subclass `HawcxError`): `EgressPolicyDenied` (host:port not
in the signed policy), `EgressHostUnreachable` (includes the SSRF refusal),
`EgressPeerCredError` (the peer-credential check failed), and
`EgressProtocolError` (malformed handshake). This transport is a
network-reachability control only — it is **not** a second authorization
gate; the RSV still enforces the tool-call mandate (ADR-0048 D48-6).

## Limitations / known gaps

- End-to-end verification against real binaries is pending alpha-2 closure of
  the RSV cascade adapter. Tests use a mock Assembler over a Unix socket.
- Framework adapters (CrewAI `BaseTool`, LangChain `Tool`) are deferred to a
  Priority 2a follow-up.
- Windows Named Pipe support uses `ctypes` against `kernel32`; pytest fixtures
  exercise the Unix path only. Windows is exercised via unit tests of the
  framing layer.

## Source-only CAA packaging (Python 0.1.9)

CAA remote build jobs use a separate API from the trusted-local `hawcx bundle`
command:

```python
from pathlib import Path
from hawcx_haap.source_bundle import build_source_bundle

result = build_source_bundle(Path("source.zip"), Path("agent.pyz"))
# result.workload_digest == "sha256:<hash of exact executable bytes>"
```

The equivalent machine-readable CLI is:

```sh
python3 -I -m hawcx_haap.source_bundle source.zip --output agent.pyz
```

Use a trusted installed interpreter/package, fixed argv, a cleared environment
and CAA-owned private paths. Never use uploaded `PYTHONPATH` or invoke a shell.
The ZIP contains runtime project files at its root. Include `__main__.py`, or
pass `--main package.module:function` for an undecorated top-level function with
no required arguments. The output refuses overwrite and runs without additional
arguments on POSIX. A trusted Python interpreter is still required; it is outside
the measured zipapp. Windows requires an independently verified launcher profile.

The builder never executes uploaded code, setup hooks, pip, requirement files or
network resolution. It parses Python 3.10 syntax and rejects statically missing
imports. Upload pure-Python dependencies as source; arbitrary LangChain/CrewAI
closures and compiled extensions are unsupported. Omit development test trees
that import unvendored test tools. Dynamic imports, newer standard-library APIs
and actual HAAP behavior still require runtime acceptance: packaging success is
not a compatibility or tenant end-to-end verdict. Bare `.pyc` files are refused.

Input is limited to 32 MiB ZIP/expanded bytes, 1,024 entries, 8 MiB per file and
1 MiB central-directory metadata. Only regular UTF-8 source/text files with
bounded portable relative paths are accepted. Symlinks, bytecode, native
extensions, duplicate/case-colliding paths, encrypted archives, ZIP64 end records,
multidisk archives and SDK/standard-library namespace replacements are rejected. CAA must run this
bounded worker without custody/signing/OAuth credentials and apply job quotas.

The fixed `hawcx_haap` runtime closure is vendored offline. The result and embedded
`_hawcx_build.json` include its SDK version, raw 64-hex digest and declared minimum
Python version. That version declares the minimum supported SDK profile; AST
syntax checks and the builder host's stdlib names do not prove that uploaded code
uses only APIs available in Python 3.10. The closure digest hashes sorted member
names/content, each as
`u32be(name length) || UTF-8 name || u64be(content length) || content`, including
the synthetic SDK `METADATA`. This is build provenance, not a signature or a
replacement for the canonical repository record. Human metadata stays in the
CAA-generated canonical record; changing a description must change that record's
identity without rewriting executable bytes. CAA remeasures the output before
finalizing and signing the record through the shared measurement contract.

## License

Hawcx Proprietary License. See [LICENSE](../LICENSE).
