Skip to main content

Information flow control for agentic systems

Project description

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

pip install strahl-python

With uv:

uv add strahl-python

Import the package as strahl:

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:

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:

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

Or dynamic, based on tool arguments:

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.

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:

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:

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:

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:

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:

@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:

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:

@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:

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.

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:

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:

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:

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:

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:

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:

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:

@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:

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:

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

Top-level helpers:

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:

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:

uv run --extra dev pytest

Run a syntax check:

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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

strahl-0.1.0.tar.gz (29.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

strahl-0.1.0-py3-none-any.whl (20.2 kB view details)

Uploaded Python 3

File details

Details for the file strahl-0.1.0.tar.gz.

File metadata

  • Download URL: strahl-0.1.0.tar.gz
  • Upload date:
  • Size: 29.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for strahl-0.1.0.tar.gz
Algorithm Hash digest
SHA256 36f3a81a6b4eb0c99c183eea57fe4abb08607df73a4e630afe9ddb458f72535c
MD5 d6ba9272521d085f332a5a0d5950ad9b
BLAKE2b-256 bc2e23d5ef3f6e314342ff1c64baebc352c0506adf4983e8cafa15d946fa4c4f

See more details on using hashes here.

Provenance

The following attestation bundles were made for strahl-0.1.0.tar.gz:

Publisher: python-publish.yml on strahl-labs/strahl-client

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file strahl-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: strahl-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 20.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for strahl-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c73faa153fad4ce590db4df51ddc8eef541915164312d9aeeaef37111a0c58c7
MD5 6865afd3f4fd8b64a4a2e8af1613727e
BLAKE2b-256 5fc4e1fb940742bea6b5a1ece6c6048c62a9eb63d840134b21472f2d4192d503

See more details on using hashes here.

Provenance

The following attestation bundles were made for strahl-0.1.0-py3-none-any.whl:

Publisher: python-publish.yml on strahl-labs/strahl-client

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page