# boundaries-as-code A working example of managing Zenity boundaries, custom taints and variables as code with [`zenityctl`](https://get.zenity.io): the policy lives in git, every change is tested and diffed on a pull request, and merging is what applies it to the account. Copy this repo, point it at your own account, replace the pack with yours. ```bash zenityctl boundaries skill # teach your coding agent to write these zenityctl boundaries validate # would the account accept this? zenityctl boundaries test # does it behave the way tests/ says? zenityctl boundaries diff # what would change if this were applied? zenityctl boundaries push # apply it ``` **Start with `skill` if you write policy with an agent.** The rest of this README explains the patterns; that command hands them to the agent doing the typing, along with the language, the file formats and the mistakes that produce a rule which compiles and never fires. ## Layout ``` boundaries/ one file per boundary, named after its id taints/ one file per custom taint, named after its id variables/ one file per variable, named after its name tests/ suites + the step fixtures they run against .github/ the PR check and the deploy job ``` The first three are **desired state**: `push` reconciles the account to exactly what is in them, so deleting a file deletes the resource. `tests/` is yours — `pull` never writes it and `push` never reads it. ## The example pack Five boundaries, two custom taints, five variables. Together they cover the patterns most real packs are built from. | Boundary | What it catches | |---|---| | `customer-data-egress` | Reading a customer record is fine; sending outward is fine; doing both in one conversation is exfiltration. Also refuses to serve a record into an unauthenticated session at all. | | `untrusted-input-guardrails` | Once a conversation has taken in something untrusted, the agent may still talk but may no longer change data, send outward, or hand off. | | `mcp-tool-controls` | Agents stay on reviewed MCP servers, and off any server running on the user's own machine. | | `agent-handoff-controls` | A handoff carries the whole conversation to another agent, so only reviewed agents may receive one. | | `9d34a2b3-…` | An Azure AI Foundry protection package created in the console — a server-generated id, so its file is named after that id. Left as pulled. | ### Patterns worth stealing **Taints carry a fact across steps.** No single step in the exfiltration case is wrong on its own. `CUSTOM.CUSTOMER_DATA_ACCESSED` is applied by a `taint` rule when a retrieval pulls a customer record, and read by a `forbid` rule on the send that comes later. That is the whole multi-step mechanism. **`taints.before` and `taints.all` are different questions.** `before` is what earlier steps left behind; `all` is that plus what this step itself contributes. The rule on assistant replies wants both — customer data from *earlier*, a link from *this* message: ``` when { resource.role == "assistant" && taints.before.contains(Taint::"CUSTOM.CUSTOMER_DATA_ACCESSED") && taints.all.contains(Taint::"SYSTEM.HAS_IMAGE_OR_URL") }; ``` **Governance values belong in `variables/`, not in rules.** `{{approved_egress_domains}}` is substituted at compile time, so widening the list is a one-line diff a reviewer can judge on sight, and no rule is touched. Lists expand to a Cedar set (`contains`, `containsAll`); a param expands to a scalar — `{{customer_record_pattern}}` is a string used directly with `like`. **Guard attributes that only exist sometimes.** The `mcp*` attributes are empty on every non-MCP tool call, so `block-unapproved-mcp-server` is guarded with `resource.origin == "mcp"` before the `unless` reads `mcpServerName`. Without it the rule fires on every ordinary tool call — `""` is not in the approved list either. There is a test that proves the guard is present. **Scope domain rules to `execute_tool`.** `outboundDomains` is derived from a tool call's arguments, so on `chat`, `retrieval` or `memory` it is always empty and a domain rule there silently never fires. **This pack compares against a variable, not `trustedDomains`.** `untrustedOutboundDomains` is the residual of `outboundDomains` minus the trusted domains configured in Zenity — so until that list is configured it is always empty, and a rule built on it never fires. A variable is configured in this repo, where the test suite can prove it works. ## The tests `zenityctl boundaries test` runs the local pack — including edits you have not pushed — against the suites in `tests/`. Nothing is written to the account. A fixture is **one step, exactly as the Zenity console gives it to you**: open a step, copy the JSON, save it under `tests/fixtures/`. Don't write one by hand. The ones here are real exports with the names, addresses and content replaced. A case names a fixture, states the thread state it starts from, and asserts the outcome: ```yaml - name: mailing outside the approved domains after reading a record is blocked taints: [CUSTOM.CUSTOMER_DATA_ACCESSED] # what earlier steps left behind set: # dotted-path patches on the fixture context.outboundDomains: [filesharing.example] expect: allow: false firingBoundaries: [customer-data-egress/block-send-to-unapproved-destination] ``` Three things about these suites are deliberate, and worth copying: - **Every blocked case has a negative control.** "The same send on a clean thread is allowed" is what makes the blocked case mean something rather than passing for the wrong reason. - **Taints do not carry between cases.** "This step taints the thread, so the next one is blocked" is two cases: one asserting the taint fires, one seeding it with `taints:`. - **`taints: []` and an omitted `taints:` are different.** `[]` is a clean thread; omitted means "whatever the export observed". The same applies to assertions — `firingBoundaries: []` asserts nothing fired, while leaving the key out checks nothing at all. `set` patches one fixture into many cases, so one export covers the allowed path, the blocked path and the control. It warns when you patch half of a coupled pair (`resource.attributes.toolName` with `context.output.toolName`), and it cannot reach taint state — that is what `taints:` is for. Exit codes are distinct on purpose: **5** means the pack was refused and nothing ran, **8** means it compiled but behaves wrong. ## The CI flow **On a pull request** (`.github/workflows/boundaries-pr.yml`) — validate, test, then write the diff against the account into the job summary, so a reviewer can see exactly what merging would change without opening a console tab. The diff step never fails the build; drift on a PR branch is the normal state. **On merge** (`.github/workflows/boundaries-deploy.yml`) — the same two gates, then `push --yes`. It runs in a GitHub environment (`zenity-account`) so required reviewers gate the apply, and a concurrency group keeps two pushes from racing. > The deploy job triggers on `main`. Point it at whatever branch you merge into. ### Setting it up Repository **variables**: `ZENITY_ENV` (`eu` / `us`), `ZENITY_ACCOUNT` (`org_…`), and optionally `ZENITYCTL_VERSION` to pin the CLI. Repository **secrets**: `ZENITY_CLIENT_ID`, `ZENITY_CLIENT_SECRET` — an account API key. There is no login command and nothing is cached on disk; both are read from the environment on every invocation. Two things that will bite on the first run: - **`push` requires the `operator` role, and power mode must be enabled by an account admin.** Only the apply is gated, so `validate`, `test` and `diff` all pass first and the wall arrives at the merge. Keep the workflow on `pull_request` (not `pull_request_target`) so a fork PR cannot reach the key at all. - **The environment and account have no defaults.** Miss either and every command exits `2` before making a request — `no environment selected`. Set them once for the job rather than passing `--env` per command. ## Authoring with an agent The two things an agent cannot work out on its own are what the policy language allows and what your account's data actually looks like. Both are one command each. ```bash zenityctl boundaries skill # once per clone, and again after `zenityctl update` zenityctl boundaries schema # what a rule may reference, for this account ``` `skill` installs guidance into `.claude/skills/boundaries-authoring/` covering the language, the file formats, how taints carry a fact from one step to the next, and the failure modes that are hard to see — chiefly a rule that compiles, validates, applies, and then never fires. It is written for an agent working *in this repo*, so it reads `taints/` and `variables/` off disk rather than asking you for values already in the tree. It is not committed. The install adds that one directory to `.gitignore`, because the skill is served by Zenity and belongs to whichever CLI version fetched it — so everyone runs the command themselves rather than reviewing a copy of it. Both commands need the same credentials as `diff`, and only the `viewer` role. The skill deliberately carries no copy of the schema. It tells the agent to run `boundaries schema` instead, so authoring stays correct as your AI services change — a written-down schema goes stale silently, and the rules built on it fail the same way. Two habits worth keeping whoever writes the rules: - **Ask for a test with every rule.** A boundary that never fires is the common failure and only a case over a real exported step catches it. See [The tests](#the-tests). - **Read the plan, not the summary.** `boundaries diff` is the same plan `push` applies, and an unintended deletion shows up there and nowhere else. ## Working on the pack ```bash export ZENITY_ENV=us ZENITY_ACCOUNT=org_… ZENITY_CLIENT_ID=… ZENITY_CLIENT_SECRET=… zenityctl boundaries pull # start from what the account has zenityctl boundaries skill # once per clone; see "Authoring with an agent" # edit boundaries/, taints/, variables/, add cases under tests/ zenityctl boundaries validate zenityctl boundaries test tests/customer-data-egress.yaml # or a subset git checkout -b tighten-egress && git commit -am … && gh pr create ``` A repo can also carry its target in a `zenityctl.yaml` beside the three directories — it takes exactly two keys, `env` and `account`. Identity comes from the `id`/`name` **inside** each file, never from the filename, so renaming a file changes nothing about what is pushed. Give every rule you assert on an explicit `@id`. Without one it takes a compiler-derived id (`forbid_0`) that moves when rules are reordered, and assertions pointing at it break silently.