kisenon
Agent-Safe Change Control

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:

FieldMeaning
schema_patternSchema name pattern (% or * = any, _ = one character). Defaults to *.
table_patternTable name pattern.
column_patternColumn name pattern.
masking_fnOne of the built-in functions below.
fn_argsFunction arguments (only mask_constant takes one: value).

At branch-create time, when a masking_policy_id is supplied:

  1. The branch forks from its parent as usual (copy-on-write — instant).
  2. The branch enters the masking state. No endpoint is provisioned and the proxy refuses connections to it: there is no window in which the pre-masked data can be read.
  3. 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.
  4. The branch lands in ready and its endpoints come up — now serving only masked data. On any failure the branch lands in failed and 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

FunctionEffect
mask_emailmd5(value)@masked.invalid
mask_nameName_ + an 8-char hash prefix
mask_nullNULL (column must be nullable)
mask_constantA 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_ip0.0.0.0
mask_date_yearKeeps the year, truncates to Jan 1
mask_hashmd5(value)
mask_shuffleHash-based scramble (full character shuffle is planned)
mask_phoneA synthetic +1-555-XXXX number
mask_uuidA 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: none

A 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 on main. 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 main database 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_email on an integer) aborts the whole mask — the branch lands in failed and is never exposed half-masked.