cofferdam
Block outbound side effects — email, API calls, payments, webhooks — from non-production environments.
A cofferdam is a temporary watertight enclosure pumped dry so work can happen on a submerged foundation. This library is the software equivalent: it holds back outbound side effects from development, staging, and test environments so they cannot reach real customers, payment processors, or live vendor APIs.
The problem
When a non-production environment is refreshed from a production database backup, the restored data includes live email accounts, API credentials, payment configuration, webhook endpoints, and scheduled job settings. Without a guard, the non-production environment behaves like production — emailing customers, charging cards, and calling live APIs against real data.
How it works
cofferdam reads a local TOML policy file that lives on the environment's
filesystem — never in the database, never in a backup. That file is the
execution authority for every outbound action.
Restored data owns business intent. Local environment config owns execution authority.
The engine answers one question per outbound action:
Is this specific side effect permitted here?
It fails closed: absent, incomplete, or invalid policy denies the action.
Installation
$ pip install cofferdam # core library + CLI
$ pip install "cofferdam[http]" # adds the policy-checked HTTP helper (httpx)
Requires Python 3.10+.
Quick start
from cofferdam import load_policy
policy = load_policy("environment_policy.toml")
# Raise PolicyDeniedError if this side effect is not explicitly permitted here.
policy.assert_allowed(
integration="windmill",
kind="vendor_api",
operation="write",
method="POST",
host="sandbox.windmill.dev",
credential="windmill_default",
)
# Or inspect the decision without raising.
decision = policy.decide(integration="stripe", kind="payment", operation="capture")
if not decision.allowed:
print(decision.reason_code) # e.g. "operation_not_allowed"
Policy-checked HTTP — host validated against the policy before any bytes leave
(optional cofferdam[http] extra):
from cofferdam.http import request
response = request(
policy=policy,
integration="windmill",
operation="write",
method="POST",
url="https://sandbox.windmill.dev/api/jobs/run",
credential="windmill_default",
json=payload,
)
Email decoration
When an email is sent from a non-production environment, cofferdam decorates
the subject and body so recipients immediately know the source:
- Subject:
STAGING - Weekly Sales Report - Body: a warning banner (HTML) or a notice line (plain text) prepended to the original content
Decoration is on by default for any non-production environment and can be
disabled with decorate = false under [mail].
Example policy
environment = "staging"
default_decision = "deny"
[mail]
mode = "sink"
sink = "dev-inbox@example.internal" # all outbound mail redirected here
[integrations.windmill]
enabled = true
kind = "vendor_api"
credential = "windmill_default"
allowed_hosts = ["sandbox.windmill.dev"]
allowed_methods = ["GET", "POST"]
allowed_operations = ["read", "write"]
[credentials.windmill_default]
profile = "staging"
secret_env = "WINDMILL_API_KEY"
[integrations.stripe]
enabled = true
kind = "payment"
credential = "stripe_default"
allowed_hosts = ["api.stripe.com"]
allow_authorize = true
allow_capture = false # never capture money outside Production
[credentials.stripe_default]
profile = "sandbox"
secret_env = "STRIPE_SANDBOX_SECRET_KEY"
[effects.email.customer]
enabled = false # no customer email in Staging
[effects.email.internal]
enabled = true
allow_domains = ["example.internal"]
CLI
$ cofferdam validate path/to/environment_policy.toml
$ cofferdam inspect path/to/environment_policy.toml
$ cofferdam decide path/to/environment_policy.toml \
--integration windmill --kind vendor_api --operation write \
--method POST --url https://sandbox.windmill.dev/api/jobs/run \
--credential windmill_default
validate exits non-zero on invalid policy. inspect prints a redacted
summary — secrets are never shown. decide prints allow/deny and a stable
reason code.
Local development
A real policy file can reference many credentials — one secret_env per
integration. Each of those environment variables has to be present in every
process that resolves it: the web server, every worker, and whatever shell a
developer or an AI pair-programming agent is using while writing integration
code. Prefixing every command with a wall of SECRET_X=... SECRET_Y=...
doesn't scale, and cofferdam deliberately doesn't solve this for you —
resolve_secret only ever reads os.environ, never an adjacent .env file
or a path named in the policy (ADR-0013).
That keeps "missing env var" a real, fail-closed condition (ADR-0005) and
keeps the policy file itself free of secret-custody concerns (ADR-0007).
Instead, provision local secrets at the shell level with
direnv, so every tool started from your project
directory — an interactive shell, bench start, pytest, cofferdam decide,
or an AI agent's own shell — inherits the same variables automatically.
Install:
$ brew install direnv # macOS
$ sudo apt install direnv # Debian/Ubuntu
Then hook it into your shell (add to ~/.bashrc or ~/.zshrc):
eval "$(direnv hook bash)" # or: eval "$(direnv hook zsh)"
Open a new shell (or source the rc file) after adding the hook.
Use: create a .envrc file next to environment_policy.toml (or at the
project root) exporting one variable per secret_env your policy references:
# .envrc — never commit this file
export WINDMILL_API_KEY="sk_test_..."
export STRIPE_SANDBOX_SECRET_KEY="sk_test_..."
Then authorize it once per directory:
$ direnv allow
From then on, cd-ing into that directory loads those variables into the
shell, and leaving it unloads them again — no manual exporting, no per-tool
setup. Add .envrc to .gitignore immediately; it holds real secret values.
direnv is a local-development convenience only — it affects shells it hooks
into, not processes started by a process manager. Staging/production workers
and the web server should get their env vars from the process manager instead
(systemd EnvironmentFile=, supervisor environment=) or a real secrets
manager, not from .envrc.
Frappe / ERPNext
cofferdam was designed with ERPNext and Frappe Framework in mind — the
database restore pattern is a daily reality for that ecosystem. The core
library has no dependency on Frappe and works with any Python application.
For full Frappe/ERPNext integration — automatic policy discovery by bench
site, interception of frappe.sendmail, and webhook delivery gating — see
cofferdam-app, a companion
Frappe app that installs into a bench alongside your ERPNext site.
Requirements specification
This library is built with a document-driven workflow. See docs/
for the full requirements specification and docs/adr/ for
architecture decisions.
License
Apache-2.0 © 2026 Brian Pond / Datahenge LLC
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 cofferdam-0.1.1.tar.gz.
File metadata
- Download URL: cofferdam-0.1.1.tar.gz
- Upload date:
- Size: 53.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
81d66e75efd7de3cab539abbf23d11d5c24d8e720fef1452754dd6a5ce0bea8e
|
|
| MD5 |
4587eff59134615f44331b69bd664110
|
|
| BLAKE2b-256 |
4256dc54454793d9ba8cb07698e1cf7968b743f3cf5fb978b96069a5fdaed20f
|
File details
Details for the file cofferdam-0.1.1-py3-none-any.whl.
File metadata
- Download URL: cofferdam-0.1.1-py3-none-any.whl
- Upload date:
- Size: 25.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
92ebcba746a93f1b358d5f6869a8888831c79fc64f1781f7b49c5163de8b073d
|
|
| MD5 |
727922f7b6a0614ac7c5fef33bc3b70a
|
|
| BLAKE2b-256 |
de06fc57936bdfb1ea31c10a9a705c0373a4672e08664f108fa33a318e6e94c5
|