Metadata-Version: 2.4
Name: strahl
Version: 0.1.0
Summary: Information flow control for agentic systems
Author: Strahl Labs
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/strahl-labs/strahl-python
Project-URL: Repository, https://github.com/strahl-labs/strahl-python
Keywords: llm,information-flow-control,agents,security,ifc
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Classifier: Intended Audience :: Developers
Classifier: Topic :: Security
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.28.1
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Provides-Extra: docs
Requires-Dist: mkdocs-material>=9.0; extra == "docs"
Dynamic: license-file

# Strahl Python SDK

Strahl helps secure agentic workloads by tracking what information is allowed to influence tool calls and where tool outputs are allowed to flow. You label users, system context, documents, and tools; Strahl analyzes provider message transcripts and returns typed decisions for each tool call.

The SDK is pre-release and intentionally small. It uses dataclasses for typed responses and has one dependency: `httpx`.

## Why Strahl

Agents often fail at the boundary between language and action:

- Prompt injection: untrusted text from a web page, email, ticket, or document convinces the model to call a powerful tool.
- Privacy leakage: data meant for one user, customer, tenant, or system context ends up influencing output visible somewhere else.

Strahl's core idea is to make that boundary explicit. Before your app executes a tool call, Strahl asks three questions:

1. What information could have influenced this call?
2. Is that information trusted enough to control this tool?
3. If the tool returns data, where is that result allowed to flow next?

You answer those questions with labels and tool flow policies. A label is not a semantic hint for the server to interpret; it is a pair of literal tag sets. A tool flow policy says which tags are required to control a tool call and which tags are produced by the tool result. If the flow does not line up, Strahl denies the call before your application executes it.

This is not a replacement for application authorization. It is a guard for the agent layer: the model can still read, summarize, and reason over untrusted context, but untrusted context should not silently become permission to send email, move money, update records, or reveal private data.

## Install

```bash
pip install strahl-python
```

With `uv`:

```bash
uv add strahl-python
```

Import the package as `strahl`:

```python
import strahl
from strahl import Label
```

## Core Ideas

Strahl uses a small information-flow model. Every piece of information in the transcript gets a label with two independent sets of tags:

- `source`: where the information came from. Use this for integrity checks, such as preventing web content from driving a payment.
- `visibility`: where the information may be shown. Use this for confidentiality checks, such as preventing customer A data from flowing to customer B.

Tools declare a flow policy when they are registered:

- `requires`: the label required for information that selects the tool and, by default, controls each argument.
- `params`: optional per-parameter requirements. If provided, every declared top-level parameter must be listed.
- `produces`: the label assigned to the tool result.

Most tools only need `requires` and `produces`. In that case, every argument inherits `requires`, so the same trust boundary applies to selecting the tool and filling in its arguments. Use `params` when arguments have different trust boundaries, such as an email tool where the recipient must come from the user but an optional tracking field must come from application context.

Strahl treats labels as literal sets of tags. It does not infer that `"system"` is related to `"internal"`, that `"customer:A"` is related to `"user:alice"`, or that `"agent"` should be trusted because it sounds like your assistant. Pick tags so the membership relationship you want is explicit:

- Put a tag in `source` when that source is trusted to control a tool call.
- Put a tag in `visibility` when that data may be shown to that audience or destination.
- Use the same tag spelling wherever two things should match.

`source` and `visibility` are separate axes. A tag in `visibility` does not make that value a trusted `source`, and a trusted `source` does not make the value safe to show everywhere.

For every tool, decide what information is allowed to influence the call and where the tool result may flow. This is the main design step when adopting Strahl. Do not start by inventing many semantically related labels and hoping the analyzer infers your intent; start by deciding which literal tags must be present for each sensitive action.

Because tags are ordinary Python strings and tag sets are ordinary Python sets, you can name your tags once and compose them with normal set operations:

```python
USER = {"user"}
ASSISTANT = {"assistant"}
SUPPORT_AGENT = {"support-agent"}
APP = {"app"}
INTERNAL = {"internal"}


def CUSTOMER(customer_id: str) -> set[str]:
    return {f"customer:{customer_id}"}


strahl.set_role_labels({
    "user": Label(source=USER, visibility=USER),
    "assistant": Label(source=ASSISTANT, visibility=USER),
    "developer": Label(source=APP, visibility=INTERNAL),
})

customer_visible_to_support = lambda customer_id: CUSTOMER(customer_id) | SUPPORT_AGENT
```

This is just Python composition. `USER | SUPPORT_AGENT` means the final label set contains both tags; it does not create a hierarchy, alias, or fuzzy semantic relationship.

`frozenset` is also supported and can be useful for shared constants you do not want mutated, but the everyday API is plain `{...}` sets.

Labels can be static:

```python
Label(source={"user"}, visibility={"user", "support"})
```

Or dynamic, based on tool arguments:

```python
Label(source={"support-agent"}, visibility=lambda customer_id: {f"customer:{customer_id}"})
```

Dynamic labels are useful when a tool's security boundary is not known until the model chooses arguments. For example, `reply_to_customer(customer_id="A", ...)` can resolve to `visibility={"customer:A"}`, while the same tool call with `customer_id="B"` resolves to `visibility={"customer:B"}`. You do not need to pre-register every customer-specific label at import time; the SDK resolves callable label fields from the actual tool-call arguments before sending the analysis request.

## Quick Start

This example registers a sensitive tool, analyzes an OpenAI-style transcript, and blocks execution if the analyzer denies the call.

```python
import os
import strahl
from strahl import Label

strahl.set_api_key(os.environ["STRAHL_API_KEY"])

strahl.set_role_labels({
    "user": Label(source={"user"}, visibility={"user"}),
    "assistant": Label(source={"assistant"}, visibility={"user"}),
})


@strahl.tool(
    requires=Label(
        source={"user"},
        visibility=lambda to: {"user", to},
    ),
    produces=Label(source={"email-tool"}, visibility=lambda to: {"user", to}),
)
def send_email(to: str, subject: str, body: str) -> str:
    ...


messages = [
    {"role": "user", "content": "Send the launch note to alice@example.com."},
    {
        "role": "assistant",
        "tool_calls": [
            {
                "id": "call_1",
                "function": {
                    "name": "send_email",
                    "arguments": '{"to": "alice@example.com", "subject": "Launch", "body": "..."}',
                },
            }
        ],
    },
]

analysis = strahl.analyze(messages)
analysis.raise_if_denied()
```

`strahl.analyze(...)` accepts a provider-style message dictionary or a list of message dictionaries. The transcript must include the assistant response as its final turn. If the final assistant response has no tool calls, Strahl returns a local empty permitted analysis without calling the server. If it has tool calls, the SDK normalizes OpenAI and Anthropic tool call formats into Strahl message types before posting to the analyzer.

If you have the model response separately, append it to the transcript before calling Strahl:

```python
messages = [{"role": "user", "content": "Send the launch note to alice@example.com."}]
response = openai.chat.completions.create(...)
messages.append(response.choices[0].message.to_dict())

analysis = strahl.analyze(messages)
analysis.raise_if_denied()
```

Strahl must see the full context it analyzes. If a response depends on hidden provider state, such as OpenAI `previous_response_id` or Conversations state, pass the full transcript instead.

## Default Client

The top-level helpers use a process-global default client:

```python
strahl.set_api_key("...")
strahl.set_role_labels({
    "user": Label(source={"user"}, visibility={"user"}),
    "assistant": Label(source={"assistant"}, visibility={"user"}),
    "system": Label(source={"system"}, visibility={"internal"}),
    "developer": Label(source={"developer"}, visibility={"internal"}),
})

strahl.add_document(
    "policy",
    "Never email internal launch plans to external recipients.",
    label=Label(source={"policy"}, visibility={"internal"}),
)

strahl.add_tool(
    name="send_email",
    fn=send_email,
    requires=Label(source={"user"}, visibility={"user"}),
    produces=Label(source={"email-tool"}, visibility={"user"}),
)
analysis = strahl.analyze(messages)
```

Use `Strahl` when you need isolated state for different agents, tenants, or test cases:

```python
from strahl import Strahl

agent = Strahl(base_url="https://api.strahl.io/v0")
agent.set_api_key("...")
agent.set_role_labels({
    "user": Label(source={"user:alice"}, visibility={"user:alice"}),
    "assistant": Label(source={"assistant"}, visibility={"user:alice"}),
    "system": Label(source={"system"}, visibility={"internal"}),
})

agent.add_tool(
    name="send_email",
    fn=send_email,
    requires=Label(source={"user:alice"}, visibility={"user:alice"}),
    produces=Label(source={"email-tool"}, visibility={"user:alice"}),
)
agent.add_document("runbook", runbook_text, label=Label(source={"ops"}, visibility={"internal"}))

analysis = agent.analyze(messages)
```

Use `Strahl.from_default()` when modules register tools through top-level decorators and you want an isolated client with those registrations:

```python
import my_tools
import strahl
from strahl import Label

agent = strahl.Strahl.from_default()
agent.set_role_labels({
    "user": Label(source={"user:alice"}, visibility={"user:alice"}),
    "assistant": Label(source={"assistant"}, visibility={"user:alice"}),
})
```

## Registering Tools

Decorator form:

```python
@strahl.tool(
    requires=Label(source={"support-agent"}, visibility=lambda customer_id: {f"customer:{customer_id}"}),
    produces=Label(source={"crm"}, visibility=lambda customer_id: {f"customer:{customer_id}", "support-agent"}),
)
def lookup_customer(customer_id: str) -> str:
    ...
```

Imperative form:

```python
def lookup_customer(customer_id: str) -> str:
    ...

strahl.add_tool(
    name="lookup_customer",
    fn=lookup_customer,
    requires=Label(source={"support-agent"}, visibility=lambda customer_id: {f"customer:{customer_id}"}),
    produces=Label(source={"crm"}, visibility=lambda customer_id: {f"customer:{customer_id}", "support-agent"}),
)
```

Use `params` when different arguments need different requirements:

```python
@strahl.tool(
    requires=Label(source={"support-agent"}, visibility={"support-agent"}),
    params=dict(
        customer_id=Label(source={"support-agent"}, visibility={"support-agent"}),
        message=Label(source={"support-agent"}, visibility=lambda customer_id: {f"customer:{customer_id}"}),
    ),
    produces=Label(source={"support-agent"}, visibility=lambda customer_id: {f"customer:{customer_id}"}),
)
def reply_to_customer(customer_id: str, message: str) -> None:
    ...
```

If `params` is omitted, every argument inherits `requires`. If `params` is provided, every declared top-level parameter must be listed, including optional parameters. Omitted optional arguments do not create argument sinks for that call.

You can also register provider tool schemas:

```python
openai_tool = {
    "type": "function",
    "function": {
        "name": "lookup_customer",
        "description": "Look up a customer record.",
        "parameters": {
            "type": "object",
            "properties": {"customer_id": {"type": "string"}},
            "required": ["customer_id"],
        },
    },
}

strahl.add_tool(
    fn=openai_tool,
    requires=Label(source={"support-agent"}, visibility=lambda customer_id: {f"customer:{customer_id}"}),
    produces=Label(source={"crm"}, visibility=lambda customer_id: {f"customer:{customer_id}", "support-agent"}),
)
```

Dynamic label lambdas may reference any subset of the tool parameters. Registration fails if a lambda references an argument the tool does not declare.

The tags in a tool flow policy must line up with the labels you assign to roles, documents, and tool results. For example, a tool requiring `source={"support-agent"}` can only be controlled by information whose source label contains the literal tag `"support-agent"`.

## Documents

Add documents when you put retrieved content, policies, tickets, files, or scraped pages into the conversation context.

```python
strahl.add_document(
    "ticket-123",
    ticket_body,
    label=Label(source={"customer:A"}, visibility={"customer:A"}),
)

strahl.add_document(
    "public-web-page",
    html,
    label=Label(source={"site:example.com"}, visibility={"public"}),
)
```

Document labels must be static. A document exists before a tool call, so labels cannot depend on future tool arguments.

## Provider Message Formats

Call `analyze()` after the assistant response that requests tool calls, before executing those tools. Tool result messages are part of the later provider transcript, but they are not the guard point for deciding whether to execute a newly requested tool.

OpenAI-style tool call:

```python
messages = [
    {"role": "user", "content": "Find my order."},
    {
        "role": "assistant",
        "tool_calls": [
            {
                "id": "call_lookup",
                "function": {
                    "name": "lookup_order",
                    "arguments": '{"order_id": "ord_123"}',
                },
            }
        ],
    },
]
```

Anthropic-style tool call:

```python
messages = [
    {
        "role": "assistant",
        "content": [
            {"type": "text", "text": "I will check that."},
            {
                "type": "tool_use",
                "id": "toolu_lookup",
                "name": "lookup_order",
                "input": {"order_id": "ord_123"},
            },
        ],
    },
]
```

## Reading Results

`analyze()` returns a typed response object:

```python
analysis = strahl.analyze(messages)

for result in analysis.results:
    print(result.name, result.status, result.decision)

    for component in result.components:
        print(component.sink_kind, component.sink_value, component.decision)
        for violation in component.violations:
            where = f"message {violation.source_ref.message_index}"
            if hasattr(violation.source_ref, "tool_result_index"):
                where += f", tool result {violation.source_ref.tool_result_index}"
            print(f"blocked influence from {where}: {violation.evidence!r}")
```

Use the convenience helpers for the common guard path:

```python
analysis = strahl.analyze(messages)

if analysis.denied:
    print(analysis.explain())

analysis.raise_if_denied()
```

`raise_if_denied()` raises `StrahlDenied`, a `PermissionError` subclass with `.analysis` and `.denied_results` attached.

You can serialize response objects:

```python
payload = analysis.to_dict()
json_text = analysis.to_json(indent=2)
```

## Patterns

### Prompt Injection Defense

Content fetched from the web should not be trusted to drive high-integrity tools:

```python
from strahl import ALL, Label

@strahl.tool(
    requires=Label(source=ALL, visibility={"public"}),
    produces=Label(source=lambda url: {f"site:{url}"}, visibility={"user"}),
)
def web_fetch(url: str) -> str:
    ...


@strahl.tool(
    requires=Label(source={"user"}, visibility={"user"}),
    produces=Label(source={"payments"}, visibility={"user"}),
)
def pay_invoice(invoice_id: str) -> str:
    ...
```

The fetched page can be analyzed and cited, but it does not become `source={"user"}`, so it cannot directly authorize `pay_invoice`.

### Per-Customer Isolation

Tie visibility to a tool argument:

```python
@strahl.tool(
    requires=Label(
        source={"support-agent"},
        visibility=lambda customer_id: {f"customer:{customer_id}"},
    ),
    produces=Label(source={"support-agent"}, visibility=lambda customer_id: {f"customer:{customer_id}"}),
)
def reply_to_customer(customer_id: str, message: str) -> None:
    ...
```

If customer A content influences a call with `customer_id="B"`, the analyzer can deny the tool call.

### Role Labels

Use role labels to describe message roles that appear in the transcript:

```python
strahl.set_role_labels({
    "user": Label(source={"user:alice"}, visibility={"user:alice"}),
    "assistant": Label(source={"assistant"}, visibility={"user:alice"}),
    "system": Label(source={"system"}, visibility={"internal"}),
    "developer": Label(source={"developer"}, visibility={"internal"}),
})
```

Every non-tool-response message role must have a label before `analyze()`.

## API Reference

Common imports:

```python
from strahl import (
    ALL,
    NONE,
    Label,
    Strahl,
    StrahlDenied,
)
```

Top-level helpers:

```python
strahl.set_api_key(api_key)
strahl.set_role_labels(labels)
strahl.tool(name=None, requires=requires, produces=produces, params=params)
strahl.add_tool(fn=fn_or_schema, requires=requires, produces=produces, params=params, name=name)
strahl.add_document(name, content, label=label)
strahl.analyze(messages)
strahl.Strahl.from_default()
```

`name` is only needed when registering a Python callable under a custom name. Omit it when registering an OpenAI or Anthropic tool schema.

Core types:

```python
Label(source=<set[str] | frozenset[str] | callable>,
      visibility=<set[str] | frozenset[str] | callable>)

strahl.tool(requires=Label(...), produces=Label(...))
strahl.tool(requires=Label(...), params=dict(arg=Label(...)), produces=Label(...))
```

Sentinels:

- `ALL = frozenset({"*"})`
- `NONE = frozenset()`

## Development

Install development dependencies and run tests:

```bash
uv run --extra dev pytest
```

Run a syntax check:

```bash
python3 -m py_compile strahl/*.py
```

## Current Scope

Strahl focuses on provenance and flow-control checks around agent tool use. It does not attempt to solve semantic secrecy policies, aggregation attacks, timing channels, or application-specific authorization. Keep those controls in your application; use Strahl as the tool-call flow analyzer.
