Open source · Apache-2.0
Keep every client in bounds.
Zence is a task-scoped policy firewall for Claude Code. It resolves the assets a tool call touches against DataHub, and refuses the ones that belong to a different client — before the call runs.
The problem
Every individual step is valid. The mistake is the combination.
Freelancers, agencies and consultancies run Claude Code across several clients from one machine. Claude Code has no concept of which client is in scope right now.
SELECT l.email, p.phone
FROM northstar.marketing_leads l -- Client A (you are here)
JOIN bluepeak.patient_contacts p -- Client B (you are not)
ON l.email = p.emailA linter sees well-formed SQL. The warehouse sees an authorised user. The agent sees a reasonable way to answer the question it was asked.
The only place this is visibly wrong is in the metadata: those two tables sit in different domains, and one of them carries personal data at column level.
That is a question a catalog can answer. Zence asks it, on every tool call, before the call runs.
Real output
Three decisions, unrolled
Every field below is real output from zence evaluate --json, copied into this page by a build step. Nothing is a mock-up.
Synthetic scenario. Northstar Commerce and BluePeak Health are fictional. These are real Zence decisions, recorded from actual runs against the demo catalog — not mock-ups — but no real company or person is represented.
Every part of this is valid. The SQL parses, both tables exist, the developer has credentials for both. The mistake only exists at the level of boundary — and nothing else in the loop is tracking that.
Cross-client PII access
What the developer asked for
“Blend our Northstar leads with the BluePeak patient contact export so we can see overlap.”
What Claude tried — Write
SELECT l.email, p.phone FROM northstar.marketing_leads l JOIN bluepeak.patient_contacts p ON p.email = l.emailWhat Zence extracted
northstar.marketing_leadshigh confidence · sqlbluepeak.patient_contactshigh confidence · sql
intent: codegen, read
What DataHub said
urn:li:dataset:(urn:li:dataPlatform:snowflake,bluepeak.patient_contacts,PROD)
Classified urn:li:tag:PII · columns email, phone tagged at field level
Decision — ZR-001
bluepeak.patient_contacts belongs to BluePeak Health, but this session is bounded to Northstar Commerce. It carries urn:li:tag:PII, and columns email, phone are classified at field level. Reading it here would move another client's personal data into this workspace.
policy v1.0.0 · enforce mode · risk critical · via rule
What Claude is told to do instead
Use an asset inside Northstar Commerce. If you genuinely need to work on BluePeak Health, switch to that client's workspace so the correct boundary applies.
The remediation is the point. A bare refusal invites a retry with a variation; naming the alternative turns it into a redirect.
Rendered from cross-client-pii.json — real output of zence evaluate --json
In bounds, and still worth a pause. DataHub lineage shows an executive dashboard two hops downstream, so this is a change someone outside the data team will notice. Zence asks rather than blocks — the change may well be correct.
Critical downstream impact
What the developer asked for
“Change the revenue model to report gross instead of net.”
What Claude tried — Edit
INSERT INTO northstar.fct_revenue_daily SELECT revenue_date, gross_revenue FROM northstar.stg_ordersWhat Zence extracted
northstar.fct_revenue_dailyhigh confidence · sqlnorthstar.stg_ordershigh confidence · sql
intent: codegen, write
What DataHub said
urn:li:dataset:(urn:li:dataPlatform:snowflake,northstar.fct_revenue_daily,DEV)
Lineage reaches 1 asset(s) this workspace marked critical:
urn:li:dashboard:(looker,northstar_revenue)Decision — ZR-008
Changing northstar.fct_revenue_daily affects 1 asset(s) the workspace has marked critical: urn:li:dashboard:(looker,northstar_revenue). DataHub lineage shows the dependency; the change itself may still be correct.
policy v1.0.0 · enforce mode · risk high · via rule
What Claude is told to do instead
Review the downstream consumers before approving, and tell their owners (urn:li:corpuser:dana) if the change alters semantics rather than implementation.
The remediation is the point. A bare refusal invites a retry with a variation; naming the alternative turns it into a redirect.
Rendered from critical-downstream.json — real output of zence evaluate --json
The most important case. In domain, in DEV, nothing sensitive — so Zence returns an empty response and the developer sees nothing at all. A guardrail that announces itself on safe work is a guardrail that gets uninstalled.
In-boundary development read
What the developer asked for
“Write a staging model over the last 30 days of leads.”
What Claude tried — Write
SELECT lead_id, source_campaign FROM northstar.marketing_leads WHERE created_at >= DATEADD(day, -30, CURRENT_DATE())What Zence extracted
northstar.marketing_leadshigh confidence · sql
intent: codegen, read
What DataHub said
urn:li:dataset:(urn:li:dataPlatform:snowflake,northstar.marketing_leads,DEV)
Decision — ZR-009
northstar.marketing_leads is inside Northstar Commerce in DEV, which this workspace permits.
policy v1.0.0 · enforce mode · risk none · via rule
Rendered from in-boundary.json — real output of zence evaluate --json
How it answers
Three outcomes, not two
A tool that can only allow or block forces a choice between being useful and being safe. Most real situations are the middle one.
In domain, permitted environment, nothing sensitive. The hook returns an empty response and the developer sees nothing at all.
Production, deprecated, unowned, or something a critical dashboard depends on. You decide; both outcomes are recorded.
Cross-client PII, cross-client writes, destructive production operations. Blocked before execution, with the evidence.
When Zence cannot reach DataHub, it does not fall back to allowing. An operation it cannot verify becomes an ask, and it says that the catalog was unreachable rather than implying the asset was clean.
How it works
A hook, a catalog, and a deterministic engine
Claude Code runs Zence before each tool call. Six steps, none of which involve asking a model whether something is allowed.
01
Normalize
The tool call becomes an action with an intent — read, write, mutate, destructive.
02
Extract
SQL through a real parser, plus dbt, shell, YAML recipes and MCP arguments. CTEs and aliases are excluded; columns are attributed through aliases.
03
Resolve
Each reference is looked up in DataHub: domain, owners, tags, glossary terms, lifecycle, schema, and two hops of downstream lineage.
04
Evaluate
Rules are field/predicate pairs over that evidence. No expression language, no eval, no model in the decision path.
05
Decide
Exactly one of allow, ask, deny — with the rule that fired, the evidence, and a remediation.
06
Record
The decision is logged, and written back to DataHub as a durable document at session end.
DataHub
The catalog is the source of truth, on both the read and the write path
Zence has no opinions of its own about your data. Everything it enforces is something DataHub already knows.
Read
Write
zence.last_review structured property. The document id is deterministic, so finalizing twice updates the record instead of duplicating it.The DataHub MCP Serveris the surface Zence intercepts — it is how Claude reads the catalog, so it is where a cross-client lookup shows up first. Zence’s own evidence lookups go through the DataHub Python SDK, because a hook must be deterministic and cannot borrow the agent’s MCP connection.
Install
Two commands, then a policy file
Zence needs uv, Claude Code, and a DataHub instance. Nothing else, and no hosted service.
/plugin marketplace add AmirmLotfy/zence
/plugin install zence@zencezence init --client "Northstar Commerce" \
--domain "urn:li:domain:northstar-commerce"What it is not
The honest limits
Zence reduces accidental and agent-mediated mistakes inside the Claude Code workflow. That is a real thing to be, and it is not the same as being a sandbox.