Masked forks
Give an agent (or a human) prod's shape and none of its PII — masking policies, the built-in function library, and how masked branches and sandboxes stay sealed until the mask commits.
MASK is the Agent-Safe Change Control track for sensitive data: it hands an agent a fork with production's shape and none of its PII. It works two ways — masked branches (for humans: dev, CI, contractors) and masked sandboxes (for agents). Both are covered below.
Data masking anonymizes sensitive columns the moment a branch is created. You attach a masking policy to the branch-create call, and the new branch's matching columns are irreversibly rewritten — hashed, nulled, truncated, or replaced — before the branch is ever reachable. The parent branch is never modified.
You'd reach for it to hand production-shaped data to development, CI, or a contractor without handing over the PII inside it: real table shapes, real row counts, fake emails.
How it works
A masking policy is a project-scoped, named set of rules. Each rule matches columns by pattern and names a built-in masking function:
| Field | Meaning |
|---|---|
schema_pattern | Schema name pattern (% or * = any, _ = one character). Defaults to *. |
table_pattern | Table name pattern. |
column_pattern | Column name pattern. |
masking_fn | One of the built-in functions below. |
fn_args | Function arguments (only mask_constant takes one: value). |
At branch-create time, when a masking_policy_id is supplied:
- The branch forks from its parent as usual (copy-on-write — instant).
- The branch enters the
maskingstate. No endpoint is provisioned and the proxy refuses connections to it: there is no window in which the pre-masked data can be read. - A masking worker connects to the branch's compute as a least-privilege role, discovers the schema, matches your rules against it, and runs every rewrite in a single transaction.
- The branch lands in
readyand its endpoints come up — now serving only masked data. On any failure the branch lands infailedand stays sealed; delete it and retry.
Rule inputs are treated as data, never as SQL: identifiers are quoted and argument values are bound as parameters at execution, so a hostile column name or constant cannot break out of the rewrite.
Built-in functions
| Function | Effect |
|---|---|
mask_email | md5(value)@masked.invalid |
mask_name | Name_ + an 8-char hash prefix |
mask_null | NULL (column must be nullable) |
mask_constant | A fixed value you supply (fn_args.value) |
mask_ssn_partial | ***-**-1234 — keeps the last 4 digits |
mask_credit_card | ****-****-****-1234 — keeps the last 4 digits |
mask_ip | 0.0.0.0 |
mask_date_year | Keeps the year, truncates to Jan 1 |
mask_hash | md5(value) |
mask_shuffle | Hash-based scramble (full character shuffle is planned) |
mask_phone | A synthetic +1-555-XXXX number |
mask_uuid | A fresh random UUID |
The catalog is also served by the API:
curl -s https://api.kisenon.com/v1/masking-functions \
-H "Authorization: Bearer $KISENON_API_KEY"Managing policies
In the console, open Project settings → Data masking to create a policy: name it, add rules (pattern columns + a function dropdown), and save. The same surface exists on the API:
curl -s -X POST \
https://api.kisenon.com/v1/projects/$PROJECT_ID/masking-policies \
-H "Authorization: Bearer $KISENON_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "dev-safe",
"rules": [
{"table_pattern": "users", "column_pattern": "email", "masking_fn": "mask_email"},
{"table_pattern": "users", "column_pattern": "phone", "masking_fn": "mask_null"},
{"table_pattern": "%", "column_pattern": "%ssn%", "masking_fn": "mask_ssn_partial"}
]
}'Then create a masked branch by adding the policy to a normal branch-create call:
curl -s -X POST \
https://api.kisenon.com/v1/projects/$PROJECT_ID/branches \
-H "Authorization: Bearer $KISENON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "masked-dev", "masking_policy_id": "'$POLICY_ID'"}'The branch reports state: "masking" while the rewrite runs; watch it
flip to ready in the console (live, via the project
event stream) or by polling GET /v1/branches/{id}.
Masked sandboxes (for agents)
The same policies mask an agent's sandbox fork, so an agent works against production's shape and never sees its PII. Attach a policy at create:
keon sandbox create --project <id> --masking-policy <policy-id>The mask runs before the sandbox activates. The sandbox reports
masked: true and its masking_policy_id, and while it is still creating
a masking progress object (state, phase, rules_total,
rules_completed, rows_affected_so_far, error) shows the rewrite
advancing.
Set a per-project floor so every sandbox is masked by default:
keon projects update <id> --sandbox-masking-policy <policy-id> # or: noneA per-request --masking-policy overrides the project default, but it can
never opt out of a mandate — a defense against an agent talking its way
out of masking. Setting the mandate is owner/admin only; agent-capability
keys are refused (403).
Fail-closed by construction. A masked fork's endpoints are born in a
masking state — the proxy refuses connections (SQLSTATE 57P05) until the
rewrite commits. If the masking step fails or is unwired, the create fails
closed: no URL is ever issued for an unmasked fork. The masking worker
connects as the least-privilege role kisenon_masker (never a superuser),
holds no cluster RBAC, and its per-run password is never persisted or logged.
Two caveats worth knowing:
- DML against masked values won't match on the parent at replay. A
statement like
UPDATE … WHERE email = 'a1b2@masked.invalid'targets a masked value that doesn't exist onmain. Schema-lane work is value-independent and promotes cleanly; value-dependent DML is not the job for a masked fork. - Foreign keys stay enforced. Masking disables user triggers
(
ALTER TABLE … DISABLE TRIGGER USER), not referential-integrity triggers. Masking a column inside a foreign-key relationship can therefore hit an RI error and fail the fork closed rather than quietly break the reference. - v1 masks the
maindatabase only.
Good to know
- Masking is one-shot, at creation. Editing a policy never touches branches that were already created with it — they keep the data shape they were born with. Re-create the branch to apply the new rules.
- A policy in use can't be deleted. Deleting a policy that a live
branch was created with returns
409 policy_in_use; delete those branches first. - Unmatched rules are skipped, not fatal. If your schema drifted and a pattern matches nothing, the branch still completes — check the rule patterns if a column you expected masked still has real data shape.
- Type mismatches fail the branch. A rule that matches a column its
function can't rewrite (say
mask_emailon aninteger) aborts the whole mask — the branch lands infailedand is never exposed half-masked.