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"
# Non-secret, environment-specific settings live in their own section; the
# credential names only where the key material comes from (ADR-0014).
[integrations.object_store]
enabled = true
kind = "file_transfer"
credential = "object_store_keys"
config = "object_store_staging"
allowed_hosts = ["s3.staging.example.internal"]
allowed_methods = ["GET", "PUT", "DELETE"]
[configs.object_store_staging]
endpoint_url = "https://s3.staging.example.internal"
bucket_name = "example-erpnext-staging"
region_name = "auto"
presigned_get_expiry_seconds = 300
[credentials.object_store_keys]
profile = "staging"
[credentials.object_store_keys.env] # a credential may carry several secrets
access_key_id = "OBJECT_STORE_ACCESS_KEY_ID"
secret_access_key = "OBJECT_STORE_SECRET_ACCESS_KEY"
[effects.email.customer]
enabled = false # no customer email in Staging
[effects.email.internal]
enabled = true
allow_domains = ["example.internal"]
A second, fuller example — object storage with structured config and a
multi-part credential — ships at
examples/environment_policy.dev-r2.toml.
Policy file reference
Every section and key the policy file accepts. Unknown keys are rejected: a typo fails at load with the offending key and the permitted keys named, rather than silently disabling a guard.
Top level
| Key | Type | Default | Meaning |
|---|---|---|---|
environment |
production | staging | test | dev |
required | Which environment this policy governs |
default_decision |
allow | deny |
deny |
Verdict when nothing more specific matches. allow in production is rejected |
[mail] |
table | — | Environment-wide mail handling |
[integrations.<name>] |
table | — | What each named integration may do |
[configs.<name>] |
table | — | Structured, non-secret settings |
[credentials.<name>] |
table | — | Where secret material comes from |
[effects.<kind>.<scope>] |
table | — | Scoped rules for effect classes that are not one named integration |
[integrations.<name>]
| Key | Type | Default | Meaning |
|---|---|---|---|
enabled |
bool | false |
A disabled integration always denies |
kind |
enum | required | vendor_api, email, payment, webhook, report_delivery, file_transfer |
credential |
string | — | Name of a [credentials.*] table |
config |
string | — | Name of a [configs.*] table |
allowed_hosts |
list[str] | [] |
Bare hostnames — not URLs |
allowed_methods |
list[str] | [] |
HTTP methods |
allowed_operations |
list[str] | [] |
Application-defined operation names |
allow_authorize |
bool | false |
kind = "payment": permit authorization |
allow_capture |
bool | false |
kind = "payment": permit capture |
An empty allowlist denies. If you pass a host, method, or operation to
decide()and the matching allowlist is empty, the answer is deny. Listing nothing is not "no opinion" — it is "nothing is permitted".
[configs.<name>] — structured, non-secret settings
Environment-specific settings your application needs but that are not secret: an endpoint, a bucket, a region, a prefix, a timeout, a feature switch. Values may be any TOML scalar, array, or nested table, and cofferdam does not interpret them — no policy decision depends on a config value.
[configs.object_store_dev]
endpoint_url = "https://abc123.r2.cloudflarestorage.com"
bucket_name = "foo-erpnext-dev"
region_name = "auto"
attachment_prefix = "sites/foo/attachments/"
presigned_get_expiry_seconds = 300
delete_on_file_delete = false
allowed_extensions = ["pdf", "png"]
[configs.object_store_dev.retry] # nested tables are fine
maxAttempts = 3 # so is CamelCase — keys are never rewritten
- Keys keep their exact casing. Nothing is lower- or upper-cased, so a key
your vendor's SDK spells
maxAttemptsstaysmaxAttempts. - Do not put secrets here.
cofferdam validate --strictrejects config keys whose names indicate secret material (password,secret,token,api_key,private_key, …) and points you at[credentials.*]. Identifiers are exempt:account_idandaccess_key_idpass,secret_access_keydoes not. The check reads key names only, never values. - A reference from an integration must resolve:
config = "typo_here"fails at load, not at 3 a.m.
[credentials.<name>] — where secret material comes from
A credential names where a secret lives. It is not the secret. profile is a
free-form label recorded in the policy and shown by cofferdam inspect; the
decision engine does not interpret it.
One secret, from the environment — the common case:
[credentials.windmill_default]
profile = "staging"
secret_env = "WINDMILL_API_KEY"
Several related secrets — an access-key pair, for example. The env
sub-table maps a logical name you choose to the variable holding it:
[credentials.object_store_keys]
profile = "dev"
[credentials.object_store_keys.env]
access_key_id = "R2_ACCESS_KEY_ID"
secret_access_key = "R2_SECRET_ACCESS_KEY"
A raw inline secret — parses, but is refused unless you explicitly opt in at
the call site, and --strict rejects it outright. Using it makes the policy file
itself secret material:
[credentials.legacy]
profile = "dev"
secret_value = "…" # discouraged; see ADR-0007
A credential uses the env table or a single-valued source, never both —
declaring both is a load-time error rather than a silent precedence rule.
[mail]
| Key | Type | Default | Meaning |
|---|---|---|---|
mode |
deny | sink | allow_internal |
deny |
How outbound mail is handled |
sink |
string | — | Address everything is redirected to when mode = "sink" |
allow_domains |
list[str] | [] |
Domains permitted when mode = "allow_internal" |
decorate |
bool | true |
Label non-production subject and body; false opts out |
[effects.<kind>.<scope>]
| Key | Type | Default | Meaning |
|---|---|---|---|
enabled |
bool | false |
Whether that scoped class of effect is permitted |
allow_domains |
list[str] | [] |
Domains permitted for that scope |
Resolving config and secrets
An integration can name both a config and a credential, and your application resolves each independently — settings from the policy file, secrets from the process environment:
integration = policy.integrations["object_store"]
config = policy.resolve_config(integration.config) # -> dict, keys as written
secrets = policy.resolve_credentials(integration.credential) # -> {logical name: secret}
client = S3Client(
endpoint_url=config["endpoint_url"],
bucket_name=config["bucket_name"],
region_name=config.get("region_name", "auto"),
access_key_id=secrets["access_key_id"],
secret_access_key=secrets["secret_access_key"],
)
| Call | Returns | Raises |
|---|---|---|
policy.resolve_config(name) |
A copy of the config table; mutating it cannot alter the policy | ConfigError if undefined — a typo fails closed instead of yielding an empty dict |
policy.resolve_secret(name) |
One secret, from secret_env |
SecretResolutionError if the variable is unset |
policy.resolve_credentials(name) |
Every secret in the env table |
SecretResolutionError naming the first unset variable |
policy.resolve_json_secret(name) |
A secret that is itself a JSON object, parsed | SecretResolutionError if it is not a JSON object — the value is never echoed |
No resolver ever puts a secret value into a log line, an exception message, or a traceback.
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
Release files for cofferdam 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| cofferdam-0.2.1.tar.gz | 70.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| cofferdam-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size:102.5 kB
Release files / cofferdam-0.2.1.tar.gz
| Download URL | cofferdam-0.2.1.tar.gz |
|---|---|
| Size | 70.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
44dd9aaa2d455394eaeb1edac8ff1ed98f75351322437eb911b7c8b5c695a26e
|
|
BLAKE2b-256 checksum How to use checksums |
a90b535b272658808e224a4728ce7893961bd721e216d3b6cee54d6112df2f79
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Release files / cofferdam-0.2.1-py3-none-any.whl
| Download URL | cofferdam-0.2.1-py3-none-any.whl |
|---|---|
| Size | 32.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b439a3e7fca4a53a4e94964697e9b253dd8404d966c12e7e807d1453087ad941
|
|
BLAKE2b-256 checksum How to use checksums |
fd335c915041594cb3f729019d63bf97a6b942ac51e52d7010e2f042af406506
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|