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.0.tar.gz (27.3 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.0-py3-none-any.whl (26.0 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: parlok-0.1.0.tar.gz
  • Upload date:
  • Size: 27.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.3

File hashes

Hashes for parlok-0.1.0.tar.gz
Algorithm Hash digest
SHA256 4e739f5fe5bab6ab65d5ba622b130d875b8620cd8c3e8573ad21dc04e1b9efcd
MD5 768b379c21dbe4176341d8ddba3488c9
BLAKE2b-256 b41a19249cf8b0a19946f94bbb172708eacdf96bd7f912bc66f96b2a457332c4

See more details on using hashes here.

File details

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

File metadata

  • Download URL: parlok-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 26.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.3

File hashes

Hashes for parlok-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e41bb87803d811534493adb25187cf8dca8905296607caa2fdfa6643a09f71b7
MD5 d56570314b0e003f72098e73f9fe797a
BLAKE2b-256 dcfdcca1fd4ba65bfa8d8d92e25c16e42db1f66434f1aa7507b0b6ba4f7754de

See more details on using hashes here.

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