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, Slackchat_postMessageadapter end-to-end, human-in-the-loop approvers (Slack Block Kit card + generic webhook), SQLite state store + audit log,parlok init/lint/testCLI.
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-policymatch/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 viabody.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) andWebhookApprover(POST + callback). Starlette ASGI factoryparlok.hitl.approvals_app([...])for the callback endpoint. - Slack adapter.
parlok.adapters.slack.SlackAdapterpolicy-wrapschat_postMessage; otherWebClientmethods pass through. - SQLite state + audit log. Per-decision audit row, first-time recipient tracking, pending-approval persistence.
- CLI.
parlok initwrites a starterparlok.yaml;parlok lintvalidates;parlok testshows 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_postMessageend-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
postgresadapter (and friends).- Pattern-match destructive statements (
DROP TABLE,TRUNCATE, massDELETE, ...). - 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:
- Publish v0.1.0 manually once (
python -m build && python -m twine upload dist/*) to register the project name. - On the PyPI project page, Settings → Publishing → add a Trusted Publisher:
- Owner:
Brandon-Hale, Repository:parlok - Workflow filename:
publish.yml - Environment name:
pypi
- Owner:
After that, tag pushes publish automatically. No secrets live in GitHub.
License
MIT — see LICENSE at the repo root.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
10e93d15af846930cd7a9915dc4790cdd7c7d7919005221cfe76ebc98ba9454d
|
|
| MD5 |
59a56d9807ead2460baa814cb88de0cf
|
|
| BLAKE2b-256 |
a51b1704a5654ea4247dad2d8a2a0154d94ac0632137f461b21a50dc88d83e23
|
Provenance
The following attestation bundles were made for parlok-0.1.2.tar.gz:
Publisher:
publish.yml on Brandon-Hale/parlok
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
parlok-0.1.2.tar.gz -
Subject digest:
10e93d15af846930cd7a9915dc4790cdd7c7d7919005221cfe76ebc98ba9454d - Sigstore transparency entry: 1368290388
- Sigstore integration time:
-
Permalink:
Brandon-Hale/parlok@332922c9a726bb69b47e45929f8a0a99222ec089 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/Brandon-Hale
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@332922c9a726bb69b47e45929f8a0a99222ec089 -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3116dbd8bbb456f57a87293708a167b8e3a4b896afe73e0a2079442290c743bc
|
|
| MD5 |
ce10581df771de8fdcb5267c71328196
|
|
| BLAKE2b-256 |
843a36e4d1c6d1cfd19fe1b86d51897ab48a6cdd114e0f6d59fa7aa3a7a1e351
|
Provenance
The following attestation bundles were made for parlok-0.1.2-py3-none-any.whl:
Publisher:
publish.yml on Brandon-Hale/parlok
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
parlok-0.1.2-py3-none-any.whl -
Subject digest:
3116dbd8bbb456f57a87293708a167b8e3a4b896afe73e0a2079442290c743bc - Sigstore transparency entry: 1368290393
- Sigstore integration time:
-
Permalink:
Brandon-Hale/parlok@332922c9a726bb69b47e45929f8a0a99222ec089 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/Brandon-Hale
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@332922c9a726bb69b47e45929f8a0a99222ec089 -
Trigger Event:
push
-
Statement type: