Metadata-Version: 2.4
Name: allem
Version: 0.1.0
Summary: Allem SDK — AI agent governance client
Author: Allem
License: Proprietary
Keywords: allem,ai,agents,governance,compliance
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: httpx>=0.24.0
Provides-Extra: signing
Requires-Dist: cryptography>=41.0; extra == "signing"
Requires-Dist: canonicaljson>=2.0; extra == "signing"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: cryptography>=41.0; extra == "dev"
Requires-Dist: canonicaljson>=2.0; extra == "dev"

# Allem Python SDK

Check AI agent actions before execution. Log actions for compliance.

This is the **governance SDK** — Tier 1 in Allem's connection-tier model: `check_action()` before
the action happens, `log_action()` after, a durable spool so nothing is lost, and local enforcement
of hard gates when Allem is unreachable. It is a different package from `allem-api` (a convenience
client for the REST admin surface) and from `allem-cli` (the Tier 2 coding-agent hooks). Only this
one blocks locally during an outage.

## Install

```bash
pip install allem
```

## Quick start

```python
from allem import AllemClient

allem = AllemClient(
    api_key="alm_sk_test_...",
    endpoint="http://localhost:8000",
)

verdict = allem.check_action(
    agent_id="my_agent",
    action_type="send_email",
    parameters={"to": "client@example.com"},
    affects_external=True,
)

if verdict:
    send_email(...)
else:
    print(f"Blocked: {verdict.explanation}")
```

`Verdict` is truthy when the action is allowed, so `if verdict:` works
directly. Inspect `.severity`, `.recommended_action`, `.event_id`, and
`.freeze` for richer detail.

## Fire-and-forget logging

For events that already happened (webhooks, retroactive logs):

```python
allem.log_action(
    agent_id="my_agent",
    action_type="send_email",
    parameters={"to": "client@example.com"},
)
```

`log_action` never raises — logging must never break the agent.

## Fail-open design

If the Allem API is unreachable, `check_action` returns
`Verdict(allowed=True, ...)`. An Allem outage will not block the customer's
agent. The condition is recorded in `verdict.explanation` so calling code can
log it.

Only two things ever produce `allowed=False`: a verdict Allem actually returned, and the local hard
gate below. An auth failure, a 500, or a response body the client cannot parse is an *absence* of a
verdict, not a denial, and fails open like any other outage.

## Hard gates: the one thing that fails closed (Tier 1 only)

A scope rule the customer marks `hard_gate: true` is their fail-**closed** exception to the rule
above, and setting it means *Allem's availability becomes your availability for those actions*. On
this package the gate holds even when we are unreachable — a gate that only holds while Allem is up
opens during exactly the incident it was bought for.

The SDK keeps a cached list of which action types are hard-gated for each agent
(`GET /agents/{id}/scopes/hard-gates`, resolved through `GET /agents/me`), refreshed off the latency
path after a successful check. When the platform does not answer:

| Situation | Outcome |
|---|---|
| Action is **not** hard-gated | allowed — fail-open, unchanged |
| Action **is** on the cached list | **denied locally** |
| Cached list is **stale** | hard-gated entries **still block** |
| No list has **ever** been fetched | allowed, and warned on every check |

A stale cache does not relax the gate. Age makes the list less *complete* — a rule added during the
outage is unknown to the SDK — not less binding on what it already holds.

The never-fetched row is the honest limit: the SDK cannot enforce a list it has never seen, so it
proceeds and says so through the `allem.client` logger, **every time**, naming the endpoint and the
reason. Watch for that warning; it means hard gates are not being enforced locally.

A locally-denied action still reaches the chain. It is spooled like any other event, annotated with
which gate blocked it, the `scope_version` the list came from, and how stale the cache was — so an
auditor can tell a gate that worked from a gate that guessed.

**Configuration.** The cache lives under `ALLEM_HOME` (default `~/.allem/state/`, owner-only), with
a 300s staleness bound overridable by `ALLEM_HARD_GATE_TTL_S`. On a read-only filesystem — a
container or a Lambda — it falls back to memory for the process lifetime and logs once; the gate
still blocks, it just has to be refetched on each cold start. `check_action` is never failed because
a cache file could not be written.

**Tier 2 is different, deliberately.** The coding-agent hooks in `allem-cli` do **not** block during
an outage: a hook that broke a developer's session would be uninstalled within the hour, and the
agent could reach the same result through another tool path anyway. If you read "hard gates fail
closed", it is true of this package and not of that one.

## Authentication

The SDK sends the API key in the `X-API-Key` header. Use a test-mode key
(`alm_sk_test_...`) when developing against a non-production endpoint.

## Completeness: sequences and the durable spool

Every event the SDK emits carries a client-generated sequence block —
`{run_id, client_seq, signature, signature_method}` — numbered 1, 2, 3, …
per agent per process. The server can therefore prove not only that stored
records were never altered (the hash chain), but that no record is
*missing*: a sequence number that never arrives is a detectable gap.

Before any network attempt, each event is written to a local SQLite spool
(default `~/.allem/spool.db`, override with `ALLEM_SPOOL_DIR` or the
`spool_path` argument). A background thread delivers spooled events and
retries forever — backoff 1s → 32s, then every 60s. If the process exits
with events undelivered, the next process replays them. Nothing is ever
silently lost, and fail-open behaviour is unchanged: an Allem outage never
blocks your agent.

### Signing the sequence (optional, recommended)

The sequence number is a *claim your agent makes*. Signing it lets Allem check
that claim — and, because your agent holds the private key and Allem holds only
the public half, **Allem cannot manufacture a claim on your behalf**.

```bash
pip install 'allem[signing]'
python -m allem.sequence --out ~/.allem/agent.key   # private key, written 0600
# register the PUBLIC key printed by that command:
#   POST /v1/agents/{agent_id}/signing-keys
export ALLEM_SIGNING_KEY_PATH=~/.allem/agent.key
```

Or `AllemClient(api_key=..., signing_key_path="~/.allem/agent.key")`.

The signature covers a digest of the event body, not just its position, so a
signature cannot be lifted from one event onto another. **Your private key is
never sent to Allem**, and the registration endpoint refuses anything that
looks like one.

If a signature fails to verify, Allem records the failure and raises a critical
finding — **it still accepts the event**. Enforcement fails open; that does not
change.

Full details: `docs/completeness-signing.md`.

`ALLEM_SIGNING_SECRET` (HMAC-SHA256) is still accepted so existing deployments
keep running, but Allem has never held that secret and therefore cannot verify
those signatures: they are recorded as unverified. The SDK warns once at
construction. Use a key path instead.

### Run lifecycle and heartbeats

```python
client.start_run("billing-bot")   # run_start (client_seq 1), then a beat every 60s
...
client.close()                     # run_end
```

Heartbeats make silence mean something specific. Without them, "the agent did
nothing" and "the agent stopped reporting" are the same observation. With them,
the server opens a **blackout window** when an agent goes quiet past three
missed beats, and closes it when your spool replays — so an outage becomes a
bounded, recorded fact instead of a hole in the log.

A heartbeat consumes a `client_seq` like any other event, which is the point: a
missing heartbeat is itself a detectable gap.

`start_run` is an explicit call because `run_start` must take `client_seq = 1` —
any `log_action` for that agent would consume that number first. `run_end` is
also emitted on `atexit` and SIGTERM (chained onto your own handler, never
replacing it).

Call `client.flush(timeout=...)` before process exit to drain the spool if
you want delivery confirmation; it is never required for correctness.

### v1 limitations (stated openly)

- **Spool encryption:** the spool file is *not encrypted at rest* in v1. It
  is created with restrictive permissions (`0700` directory, `0600` file),
  but it may contain sensitive action parameters. If you operate under
  regulatory requirements (healthcare, financial services), place
  `ALLEM_SPOOL_DIR` on an encrypted volume until spool encryption ships.
- **One run per process:** sequence state lives in memory, so serverless /
  one-shot agents produce many short runs (seq 1–2 each). Completeness
  still holds within each run, but gap detection is nearly vacuous for
  such workloads. Cross-process sequence persistence is deliberately not
  attempted in v1.
- **Completeness covers instrumented call sites only.** Code paths that
  never call the SDK reserve no sequence numbers and are outside the
  guarantee.
