Skip to main content

Guardrails for agents. A firewall for agent tool calls.

Project description

parlok (Python SDK)

Guardrails for agents. A lightweight runtime firewall that wraps agent tool calls and evaluates each one against declarative policies before it executes.

Status: v0.1.0 — first usable cut. YAML policy engine, rewrite/approve/deny outcomes, Slack chat_postMessage adapter end-to-end, human-in-the-loop approvers (Slack Block Kit card + generic webhook), SQLite state store + audit log, parlok init/lint/test CLI.


What parlok does

Agents call tools. Tools have side effects. A hallucinated argument, a wrong recipient, or a runaway loop can send a Slack blast to the wrong channel, issue a refund from a fictional ticket, or DROP TABLE a production database.

parlok sits between the agent and the tool SDK. Every call is normalised into a ToolCall, evaluated against a policy file, and routed to one of four outcomes:

Decision Meaning
Allow Let the call through unchanged.
Rewrite Mutate arguments before sending (redact PII, clamp length, strip URLs, etc.).
Approve Block until a human confirms (Slack approval card, webhook, etc.).
Deny Refuse the call; return a structured reason to the agent.

The agent code stays the same — the wrapped adapter exposes the underlying SDK's API surface. Policy lives in a separate YAML file owned by ops/security, not in the agent prompt.


Architecture (the four primitives)

The whole SDK is four small concepts. Everything else is implementation detail.

        ┌─────────────────────────────────────────────────────────┐
        │                       Firewall                          │
        │   loads policies, decides Allow / Rewrite / Approve /   │
        │                  Deny for each ToolCall                 │
        └─────────────────────────────────────────────────────────┘
                          ▲                       │
                          │ ToolCall              │ Decision
                          │                       ▼
        ┌─────────────────────────────────────────────────────────┐
        │                       Adapter                           │
        │   wraps one external SDK (Slack, Resend, Twilio, ...)   │
        │   - normalise(method, kwargs) -> ToolCall               │
        │   - execute(call, decision)   -> enacts the outcome     │
        └─────────────────────────────────────────────────────────┘
                                    │
                                    ▼
                            real SDK call site

ToolCall — the normalised envelope

Every adapter converts its native call shape into a ToolCall so policies can be written against one schema regardless of which SDK is wrapped.

@dataclass
class ToolCall:
    adapter: str                             # "slack", "email", "postgres", ...
    action: str                              # "chat_postMessage", "send", "execute", ...
    recipient: str | None = None
    body: str | None = None
    subject: str | None = None
    metadata: dict[str, Any] = field(default_factory=dict)

Decision — the policy outcome

DecisionKind = Literal["allow", "rewrite", "approve", "deny"]

@dataclass
class Decision:
    kind: DecisionKind
    reason: str | None = None
    payload: dict[str, Any] = field(default_factory=dict)

    def apply_rewrite(self, call): ...   # NotImplementedError until v0.1

Adapter — the integration boundary (ABC)

Every integration (Slack, Resend, Twilio, Postgres, ...) implements two methods:

class Adapter(ABC):
    name: str

    @abstractmethod
    def normalise(self, method: str, kwargs: dict) -> ToolCall: ...

    @abstractmethod
    async def execute(self, call: ToolCall, decision: Decision): ...

normalise translates the SDK's call shape into a ToolCall. execute enacts whatever the firewall decided — sending the call (Allow), sending it modified (Rewrite), parking it for review (Approve), or refusing it (Deny).

Firewall — the orchestrator

Loads a policy file, evaluates each ToolCall against it, and produces a Decision. Wraps an adapter so the agent code can keep using the underlying SDK's API.

class Firewall:
    def __init__(self, config: dict | None = None): ...

    @classmethod
    def from_file(cls, path: str | Path) -> "Firewall": ...   # v0.1

    def wrap(self, adapter: Adapter) -> Adapter: ...          # v0.1

Target API (v0.1)

This is what the landing page advertises and what v0.1 will deliver:

import os
from parlok import Firewall
from parlok.adapters.slack import SlackAdapter

fw = Firewall.from_file("firewall.yaml")
slack = fw.wrap(SlackAdapter(token=os.environ["SLACK_TOKEN"]))

# Same API as the Slack SDK — now policy-checked.
await slack.chat_postMessage(channel="#sales", text="Closed a deal")

Example firewall.yaml:

version: 1
policies:
  - name: external-message-approval
    match:
      adapter: [slack, email]
    when: recipient.is_external
    decision: approve
    via: slack_approval_card

  - name: redact-pii
    match:
      adapter: [slack, email]
    decision: rewrite
    transforms: [redact_pii, clamp_length(2000)]

  - name: block-prod-drops
    match:
      adapter: postgres
      action: execute
    when: body.matches("(?i)\\bdrop\\s+table\\b")
    decision: deny
    reason: "Destructive schema change blocked in production."

What works today (v0.1.0)

  • Policy file + loader. Firewall.from_file("parlok.yaml") — YAML schema validation, unknown-key rejection, per-policy match / when / decision / transforms / via / reason.
  • Policy engine. First-match-wins evaluation; unmatched calls fail closed (deny).
  • when: mini-expression language. Dotted field access, comparison, and / or / not, in, regex via body.matches("..."). No arbitrary Python.
  • Rewrite transforms. redact_pii, redact_secrets (AWS / GitHub / Slack / Bearer / JWT shapes), clamp_length(n), strip_urls, tone_check, enforce_template(pattern).
  • Approve triggers. external_recipient, contains_keywords([...]), financial_mention(), vip_recipient, after_hours(tz, start, end), bulk_send(n), first_time_recipient.
  • Human-in-the-loop approvers. SlackApproverCard (Slack Block Kit buttons) and WebhookApprover (POST + callback). Starlette ASGI factory parlok.hitl.approvals_app([...]) for the callback endpoint.
  • Slack adapter. parlok.adapters.slack.SlackAdapter policy-wraps chat_postMessage; other WebClient methods pass through.
  • SQLite state + audit log. Per-decision audit row, first-time recipient tracking, pending-approval persistence.
  • CLI. parlok init writes a starter parlok.yaml; parlok lint validates; parlok test shows decisions for sample ToolCalls.

The 91-test suite under tests/ pins the surface and behaviour.


Roadmap

v0.1 — first usable cut (messaging)

  • YAML schema + loader (Firewall.from_file).
  • Policy engine: match/when/decision evaluation.
  • Firewall.wrap(adapter) that proxies the underlying SDK and routes calls through policy.
  • First adapter: Slack (chat_postMessage end-to-end).
  • Rewrite transform library: redact_pii, redact_secrets, clamp_length, strip_urls, tone_check, enforce_template.
  • Approve triggers: external_recipient, contains_keywords, financial_mention, vip_recipient, after_hours, bulk_send, first_time_recipient.
  • HITL: Slack Block Kit approval cards + generic webhook.
  • SQLite state store + audit log.
  • CLI: parlok lint, parlok test.

v0.2 — refunds

  • Adapters for finance/payments tools.
  • Threshold-based approval (when: metadata.amount > 50000).

v0.3 — database writes

  • postgres adapter (and friends).
  • Pattern-match destructive statements (DROP TABLE, TRUNCATE, mass DELETE, ...).
  • Production-environment scoping.

Future

  • TypeScript SDK (sdk/typescript/) mirroring this surface.
  • Hosted control plane: policy distribution, audit log aggregation, approval inbox.
  • Additional adapters: Resend / SES / SMTP / Postmark, Twilio, Stripe, generic HTTP.

Install & test

cd sdk/python
pip install -e ".[dev]"
pytest

Expected: 92 tests pass.

Layout

sdk/python/
├── pyproject.toml
├── README.md                  # you are here
├── src/parlok/
│   ├── __init__.py            # public exports + __version__
│   ├── toolcall.py            # ToolCall dataclass
│   ├── decision.py            # Decision dataclass + DecisionKind literal
│   ├── adapter.py             # Adapter ABC
│   └── firewall.py            # Firewall (stubs until v0.1)
└── tests/
    ├── test_toolcall.py
    ├── test_decision.py
    ├── test_adapter.py
    ├── test_firewall.py
    └── test_smoke.py          # public-surface contract test

Design principles

  • Same API as the wrapped SDK. Agents shouldn't need to learn a new interface to get policy enforcement.
  • Policy in YAML, not code. Security/ops own the rules; engineers own the agent.
  • Fail closed on missing policy. If the firewall can't evaluate a call, it doesn't get sent.
  • Adapters are thin. The interesting logic lives in the policy engine, not in adapter glue.
  • No runtime dependencies in the core. Adapters bring their own SDK as an optional extra.

Releasing

Version is derived from git tags via hatch-vcs. To cut a release:

# from the repo root, on the main branch, with everything committed:
git tag v0.1.1                # use a real SemVer; must match ^v\d+\.\d+\.\d+
git push origin v0.1.1

The Publish GitHub Actions workflow (.github/workflows/publish.yml) fires on any v*.*.* tag push, builds an sdist + wheel from sdk/python/, runs the test suite against the built wheel, and uploads to PyPI via OIDC trusted publishing (no long-lived token required).

One-time PyPI setup before the first tag:

  1. Publish v0.1.0 manually once (python -m build && python -m twine upload dist/*) to register the project name.
  2. On the PyPI project page, Settings → Publishing → add a Trusted Publisher:
    • Owner: Brandon-Hale, Repository: parlok
    • Workflow filename: publish.yml
    • Environment name: pypi

After that, tag pushes publish automatically. No secrets live in GitHub.

License

MIT — see LICENSE at the repo root.

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

parlok-0.1.2.tar.gz (27.7 kB view details)

Uploaded Source

Built Distribution

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

parlok-0.1.2-py3-none-any.whl (26.3 kB view details)

Uploaded Python 3

File details

Details for the file parlok-0.1.2.tar.gz.

File metadata

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

File hashes

Hashes for parlok-0.1.2.tar.gz
Algorithm Hash digest
SHA256 10e93d15af846930cd7a9915dc4790cdd7c7d7919005221cfe76ebc98ba9454d
MD5 59a56d9807ead2460baa814cb88de0cf
BLAKE2b-256 a51b1704a5654ea4247dad2d8a2a0154d94ac0632137f461b21a50dc88d83e23

See more details on using hashes here.

Provenance

The following attestation bundles were made for parlok-0.1.2.tar.gz:

Publisher: publish.yml on Brandon-Hale/parlok

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

File details

Details for the file parlok-0.1.2-py3-none-any.whl.

File metadata

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

File hashes

Hashes for parlok-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 3116dbd8bbb456f57a87293708a167b8e3a4b896afe73e0a2079442290c743bc
MD5 ce10581df771de8fdcb5267c71328196
BLAKE2b-256 843a36e4d1c6d1cfd19fe1b86d51897ab48a6cdd114e0f6d59fa7aa3a7a1e351

See more details on using hashes here.

Provenance

The following attestation bundles were made for parlok-0.1.2-py3-none-any.whl:

Publisher: publish.yml on Brandon-Hale/parlok

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