From 62706c273904a2cd3a6106426369602c3ee11a60 Mon Sep 17 00:00:00 2001 From: Paul Reilly Date: Mon, 26 Oct 2026 13:57:18 -0400 Subject: [PATCH 1/3] Policy skills: safeguards get a TTL, a clearable flag, one marker per restriction and a three-part deny reason When a policy needs a marker, the skills now ask the admin two things before registering it: how long the restriction lasts and whether the person can disable it early. They name each marker for what it restricts and why, write its description as the sentence the deny reason will open with, and register one marker per restricted tool so the person can disable one safeguard while another stands. Enforcement policies now return a three-part deny reason: what triggered the restriction, the time remaining, and whether the person can ask to have the safeguard disabled. A policy cannot read a marker's expiry, so the writer stamps marked_at and the reader derives the minutes left from it and the registered TTL. The disable offer is gated on the registry's clearable flag and on clearing being armed on the gateway, so the reason never promises an exit that does not exist. Also corrects the cleanup note: deleting a marker is now refused while a policy still references it. Plugin version 1.2.3. Co-Authored-By: Claude Fable 5.1 --- dtwo/.claude-plugin/plugin.json | 2 +- dtwo/.codex-plugin/plugin.json | 2 +- dtwo/.cursor-plugin/plugin.json | 2 +- dtwo/skills/dtwo-gateway-policy/SKILL.md | 65 +++++++----- dtwo/skills/dtwo-policy-rego/SKILL.md | 121 +++++++++++++++-------- 5 files changed, 123 insertions(+), 69 deletions(-) diff --git a/dtwo/.claude-plugin/plugin.json b/dtwo/.claude-plugin/plugin.json index 5663008..3dfe591 100644 --- a/dtwo/.claude-plugin/plugin.json +++ b/dtwo/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.2.2", + "version": "1.2.3", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo", diff --git a/dtwo/.codex-plugin/plugin.json b/dtwo/.codex-plugin/plugin.json index 6d382dd..aef4d65 100644 --- a/dtwo/.codex-plugin/plugin.json +++ b/dtwo/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.2.2", + "version": "1.2.3", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo", diff --git a/dtwo/.cursor-plugin/plugin.json b/dtwo/.cursor-plugin/plugin.json index 206a2b0..735edf5 100644 --- a/dtwo/.cursor-plugin/plugin.json +++ b/dtwo/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.2.2", + "version": "1.2.3", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo", diff --git a/dtwo/skills/dtwo-gateway-policy/SKILL.md b/dtwo/skills/dtwo-gateway-policy/SKILL.md index 80d6e49..719a6c7 100644 --- a/dtwo/skills/dtwo-gateway-policy/SKILL.md +++ b/dtwo/skills/dtwo-gateway-policy/SKILL.md @@ -58,7 +58,7 @@ Match the user's goal to the **smallest** mechanism that solves it, then follow Guidance: - **Prefer one policy.** If a single call carries everything needed to decide, a single ingress or egress policy is the answer — don't reach for markers. -- **Markers are for cross-call state**, not for anything decidable from the current call alone. They add a second policy and a registry entry, so use them only when the decision genuinely depends on earlier session activity. +- **Markers are for cross-call state**, not for anything decidable from the current call alone. They add a second policy and a registry entry, so use them only when the decision genuinely depends on earlier session activity. The moment you decide on a marker, two answers must come from the user before anything is registered: how long the restriction lasts, and whether the person can disable it early. Never choose either yourself (see Managing Markers → Registering a marker). - **Intent is for session-purpose gating** and is only available when the intent tools are present. If they are not, solve the request with policies and markers and do not mention intent. - **Compose small policies over one large one** — the companion `dtwo-policy-rego` skill explains why (single-concern policies are easier to test, order, and debug). @@ -121,7 +121,7 @@ Markers are session-state flags that one policy writes and other policies read t |------|---------| | `dtwo-list-markers` | List markers in the registry (optional filters: `name` for exact FQID, `tag`). Returns the marker *vocabulary*, not which markers are currently active on a session | | `dtwo-get-marker` | Fetch a single marker by UID | -| `dtwo-create-marker` | Create a customer-tier marker (requires `namespace`, `markerId`, `description`, `minimumTtlSeconds`; optional `tags`, `clearable` — default `true`; `false` means the user cannot lift the marker through the clear flow and it leaves the session only when its TTL expires — and `displayName`, the name the user sees on the clearing page with the key beneath it). Full key is `marker::`. The tool requires only non-empty `namespace`/`markerId`; character-shape rules are validated server-side, not at the tool boundary. `internal` and `dtwo` namespaces are reserved for platform markers | +| `dtwo-create-marker` | Create a customer-tier marker (requires `namespace`, `markerId`, `description`, `minimumTtlSeconds`; optional `tags`, `clearable` — default `true`; `false` means the user cannot lift the marker through the clear flow and it leaves the session only when its TTL expires — and `displayName`, the name the person sees on the disable-safeguards page and in the Hub, with the key beneath it). Full key is `marker::`. The tool requires only non-empty `namespace`/`markerId`; character-shape rules are validated server-side, not at the tool boundary. `internal` and `dtwo` namespaces are reserved for platform markers | | `dtwo-update-marker` | Update mutable fields on a customer-tier marker (`description`, `displayName`, `tags`, `minimumTtlSeconds`, `clearable`). Changes reach a gateway on its next deploy | | `dtwo-delete-marker` | Delete a customer-tier marker. Platform-managed entries cannot be deleted | @@ -448,6 +448,10 @@ After publishing, call `dtwo-set-gateway-pipelines` again with `policyVersion: 1 Markers are session-state flags that policies write and later policies read to gate on. They give the gateway a shared, tenant- and user-scoped, TTL-bounded "notepad" that survives across tool calls and across upstream MCP servers — a marker written during a Slack call is visible during a later Jira call for the same user (until it expires or is cleared). Use them to compose small single-purpose policies that signal to each other without shared code: a **writer** policy stamps a marker when it observes something (PII in a response, a production resource touched), and a **reader** policy on a different tool/pipeline/server gates on it. +**Before you register anything, ask.** Stop and put two questions to the user, in plain words, and wait for the answers: *how long should the restriction last after the trigger?* (the TTL) and *should the person be able to disable it themselves, or only wait it out?* (`clearable`). Then count the restrictions: one marker for each tool or action the person is kept from, never one marker for the set. An agent that registers a marker with a TTL it picked, or one marker gating two tools, has not followed this skill — Registering a marker below has the full rules. + +**Two vocabularies.** With you, in the Hub, in the registry tools and in Rego, this is a *marker*. To the person in the session, a marker together with the policy that writes it and the policy that enforces it is a *safeguard*, and lifting it early is *disabling* it. Everything that person reads — the marker's `displayName` and `description`, every deny reason an enforcement policy returns — uses the safeguard words and never says "marker". + Marker tools are always available (they do not require `enable_intent_tools`). The full lifecycle — register, author writer + reader, attach, deploy — runs through this skill plus `dtwo-policy-rego` for the Rego. The Rego authoring patterns (emitting `session_writes["marker::"]`, walking `input.context.session.policies` to read, and the `writableKeySchema` gotchas) live in the companion `dtwo-policy-rego` instructions — load that skill for the writer/reader bodies. **Marker vs. general session key.** A registered marker is the right tool when the signal is meant to be **shared across policies** (a different policy reads it) or wants to be a registered, discoverable, governed session-wide indicator. For state that is **targeted to a single policy or one coordinated ingress/egress set**, an unregistered *general session key* (a bare `session_writes` key, no `marker:` prefix, no registry entry) is the lighter choice. Neither is access-isolated, and the bare keyspace isn't namespaced — see the decision table in `dtwo-policy-rego` → Session State & Markers → "Marker vs. general session key" before choosing. @@ -458,23 +462,36 @@ Marker tools are always available (they do not require `enable_intent_tools`). T ### Registering a marker -Register the marker in the vocabulary before any policy references it. **Before you call it, ask the user whether the person the marker blocks should be able to clear it** (see Clearing a marker). The default is yes; say no only for a marker whose whole point is that nobody lifts it early — a legal hold, a confirmed-breach lockdown, a regulatory cooling-off period: +Register the marker in the vocabulary before any policy references it. The registry entry is also what the person sees when they are blocked or asked to disable a safeguard, so settle four things with the user before the call: + +**1. Ask how long the restriction should last.** This is the marker's TTL: `minimumTtlSeconds` on the registry entry and `ttlSeconds` on the writer's `writableKeySchema` — set both to the same number. Ask in plain terms ("after PII shows up, how long should Slack posts stay blocked?"). If the user has no view, propose a value (an hour is a common starting point) and say what it means: the clock restarts every time the trigger fires, and the time remaining is what the person sees in deny reasons and on the disable page. Don't pick a TTL silently. + +**2. Ask whether the person can disable it.** This is `clearable` (see Clearing a marker). The default is yes; say no only for a marker whose whole point is that nobody lifts it early — a legal hold, a confirmed-breach lockdown, a regulatory cooling-off period. Decide it now: reader policies word their deny reasons against it, so changing it later means revisiting them. + +**3. One marker per restriction.** A restriction is one tool or action the person is kept from, and each one gets its own marker, written by the trigger policy and read by its own enforcement policy. When one trigger should restrict several things, count the restrictions first: "after a PII pull, block Slack posts and email" is two, so register "No Slack after PII" and "No email after PII" — the detector writes both keys, the Slack gate reads one and the email gate the other. The disable page then offers each separately, so the person can lift the email restriction while Slack stays blocked, and each can carry its own TTL and `clearable`. Two ways to get this wrong, both of which weld the restrictions together so they can only be disabled as a set: reusing one marker across several enforcement policies, and folding several tools into a single enforcement policy on a single marker. The test is the disable page: if the person could reasonably want one of the tools back while the other stays blocked, they are separate restrictions and need separate markers, even when the trigger, TTL and wording are identical. Only tools that are genuinely one capability (several read tools on the same data, say) share a marker. + +**4. Name it for the restriction, in the person's words.** The key, the `displayName` and the `description` all say what is restricted and why — not the trigger alone, and not the mechanism: + +- `markerId` — short and restriction-first: `no_slack_after_pii`, `no_email_after_phi_pull`. It is what Rego and `writableKeySchema` reference, and it appears beneath the display name wherever the person sees the safeguard. +- `displayName` — the headline: "No Slack after PII", "No email after PHI pull". +- `description` — one or two sentences giving the restriction and the trigger: "Posting to Slack is not allowed after PII was seen in a tool response in this session." Write it so an enforcement policy can use it verbatim as the first sentence of its deny reason (see `dtwo-policy-rego` → Reading the registry); then the deny reason and the disable page say the same thing. +- All three are end-user text: say "safeguard", never "marker", and keep tool and claim names out of them. ``` dtwo-create-marker( namespace = "acme", - markerId = "pii_detected", - description = "Session received PII in a tool response", - minimumTtlSeconds = 3600, - clearable = true, # optional; the default - displayName = "No email after PII" # optional; what the user sees, key beneath + markerId = "no_slack_after_pii", + displayName = "No Slack after PII", + description = "Posting to Slack is not allowed after PII was seen in a tool response in this session.", + minimumTtlSeconds = 3600, # the user's answer to question 1 + clearable = true # the user's answer to question 2 (the default) ) ``` -- The full key is `marker:acme:pii_detected`. Customer markers live under any namespace except the reserved `internal` and `dtwo`. -- `displayName` is what the person sees on the clearing page and in the Hub's Markers list, with the key beneath it as the identifier. Name it for what it restricts and why ("No email after PII"), not for the trigger alone; `description` carries the longer explanation. It must be unique across your organization, ignoring case, so a duplicate is refused. Leave it empty and those surfaces show the key; on `dtwo-update-marker`, pass `""` to show the key again. +- The full key is `marker:acme:no_slack_after_pii`. Customer markers live under any namespace except the reserved `internal` and `dtwo`. +- `displayName` is shown on the disable-safeguards page and in the Hub's Markers list, with the key beneath it as the identifier. It must be unique across your organization, ignoring case, so a duplicate is refused. Leave it empty and those surfaces show the key; on `dtwo-update-marker`, pass `""` to show the key again. - `clearable` decides whether the marker has a fast exit. `true` (the default, and what every marker registered before the flag existed has): the person can lift it early by approving a clear. `false`: the marker leaves the session **only when its TTL expires** — the clear flow never offers it (the confirm page lists only what can be cleared, and a commit that names it is refused). Weigh it against the TTL: a non-clearable marker with a day-long TTL is a day-long block with no appeal. -- **Decide `clearable` when you register the marker.** Reader policies are worded against it (their deny reasons either offer the clear or point only at the TTL), so changing it later means revisiting them. If it must change, `dtwo-update-marker` or the Hub's Markers page ("Can be cleared by the user") does it; the change reaches a gateway on its next deploy, and where clearing is armed, a deploy to a gateway whose image cannot honour the flag is refused with a message naming the markers until the image is upgraded. +- **Decide `clearable` when you register the marker.** Reader policies are worded against it (their deny reasons either offer disabling or say the safeguard cannot be disabled early), so changing it later means revisiting them. If it must change, `dtwo-update-marker` or the Hub's Markers page ("Can be cleared by the user") does it; the change reaches a gateway on its next deploy, and where clearing is armed, a deploy to a gateway whose image cannot honour the flag is refused with a message naming the markers until the image is upgraded. - **Keep the key simple.** The tool requires only non-empty `namespace`/`markerId`; the character-shape rules are validated server-side (when a writer policy is saved and at deploy), not at this tool boundary. In practice, use lowercase alphanumerics with underscores or hyphens and avoid dots, slashes, and spaces, so the key is accepted everywhere it's referenced (registry entry, `writableKeySchema` name, and `session_writes` key must all match exactly — see Authoring the writer policy). - `minimumTtlSeconds` is the **intended floor** for a writer policy's `ttlSeconds`. Adjust later with `dtwo-update-marker`. (Whether the floor is enforced, and the keep-in-sync caveat, are stated once under Authoring the writer policy → `ttlSeconds` — the canonical note.) @@ -482,9 +499,9 @@ dtwo-create-marker( A policy that emits a marker must declare the key in its `writableKeySchema` (on `dtwo-add-policy` / `dtwo-update-policy`), or the gateway drops the write. Each entry is: -- `name` — the session-state key, matching the registered marker **exactly** (e.g. `marker:acme:pii_detected`). Keys are exact-match and never normalized, so this `name`, the `session_writes` key, and the registered marker FQID must be byte-identical (including case) or the write silently drops. The tool accepts any non-empty string; marker-key *shape* (allowed characters, reserved prefixes) is validated server-side on save/deploy, and registry *existence* is enforced at deploy time — the deploy fails on an unregistered key (see the Deploy-time validator note below). +- `name` — the session-state key, matching the registered marker **exactly** (e.g. `marker:acme:no_slack_after_pii`). Keys are exact-match and never normalized, so this `name`, the `session_writes` key, and the registered marker FQID must be byte-identical (including case) or the write silently drops. The tool accepts any non-empty string; marker-key *shape* (allowed characters, reserved prefixes) is validated server-side on save/deploy, and registry *existence* is enforced at deploy time — the deploy fails on an unregistered key (see the Deploy-time validator note below). - `jsonSchema` — a **stringified JSON object** (a JSON Schema) for the value the policy writes. Rejected at the tool boundary if it doesn't parse as a JSON object (arrays and primitives fail). Use it strictly (`additionalProperties: false`, `required` lists) so drift is caught. Add `"x-d2-is-marker": true` for marker keys. -- `ttlSeconds` — per-key TTL. For a marker key this **should** be ≥ the marker's registered `minimumTtlSeconds`, but keep them in sync manually: the floor is **not yet enforced** (no save-time or deploy-time check today), so a lower value currently saves, deploys, and simply expires early. **Every write resets the TTL**: a re-emission is an upsert that restarts the clock from that write, which is the intended behaviour (the restriction outlasts the *latest* trigger). It also means a writer that emits on every tool call never lets its marker expire — emit on the triggering condition, not unconditionally. +- `ttlSeconds` — per-key TTL. For a marker key this **should** be ≥ the marker's registered `minimumTtlSeconds`, but keep them in sync manually: the floor is **not yet enforced** (no save-time or deploy-time check today), so a lower value currently saves, deploys, and simply expires early. The reader's deny reason derives its time remaining from the registry value, so a mismatch also shows the person the wrong number. **Every write resets the TTL**: a re-emission is an upsert that restarts the clock from that write, which is the intended behaviour (the restriction outlasts the *latest* trigger). It also means a writer that emits on every tool call never lets its marker expire — emit on the triggering condition, not unconditionally. - `onDrop` — behavior when a write fails the schema: `"drop"` (default) silently drops the write (best-effort markers); `"deny_request"` hard-denies the tool call (use for security-critical writes so bugs surface loudly instead of silently letting the call through). ``` @@ -494,7 +511,7 @@ dtwo-add-policy( packageName = "acme.egress.pii_detector", policy = , writableKeySchema = [{ - name: "marker:acme:pii_detected", + name: "marker:acme:no_slack_after_pii", jsonSchema: "{\"type\":\"object\",\"required\":[\"marked_at\",\"source_action\"],\"properties\":{\"marked_at\":{\"type\":\"integer\",\"minimum\":0},\"source_action\":{\"type\":\"string\",\"minLength\":1}},\"x-d2-is-marker\":true,\"additionalProperties\":false}", ttlSeconds: 3600, onDrop: "deny_request" @@ -502,6 +519,8 @@ dtwo-add-policy( ) ``` +A trigger that restricts several things lists one entry per marker — same `jsonSchema`, usually the same `ttlSeconds`, one per key (`marker:acme:no_slack_after_pii`, `marker:acme:no_email_after_pii`). Keep `marked_at` in the value and in the schema: the reader turns it into the time remaining it shows the person. + ### Attaching, deploying, and reading 1. Author the reader policy (walks `input.context.session.policies` for the marker key — see `dtwo-policy-rego`). The reader needs no `writableKeySchema`; it only reads. @@ -526,13 +545,13 @@ What the shape means when you design a marker or word a deny reason: - **The clear is not a management tool.** It arrives as an argument-less tool on the platform tool surface the gateway injects — `disable_safeguards`, alongside `clear_intent` and `set_intent` (on the wire, `dtwo-platform-intent-disable-safeguards` — the federated name is hyphenated throughout). Gateways running an older image publish it as `clear_markers` (`dtwo-platform-intent-clear-markers`); it behaves the same. There is no `dtwo-*` call that lifts a marker, and nothing to declare per policy. - **No arguments, deliberately.** The agent cannot name a key, so it cannot choose the target. The confirm page enumerates what is actually live and unexpired in the caller's scope, and the person selects from that list. - **The approver must be the same identity as the caller.** The browser login is matched against the identity the agent is calling with, so a person clears their own session state — not another user's. One login authorizes exactly one clear. -- **A marker can opt out of clearing.** A registry entry with `clearable: false` is never offered: the confirm page lists only the markers the person can clear, and a commit that names a non-clearable one is refused. When nothing in the session can be cleared — no markers, or only non-clearable ones — the request itself answers so, in the tool result, without minting a link: nobody is sent through a fresh sign-in to an empty page. The answer names no markers; which ones are held is only ever shown after the sign-in. When you know a marker is non-clearable (check `dtwo-get-marker` / `dtwo-list-markers`), say so when the person asks about it, and word the writer's deny reason to point at the TTL only (see `dtwo-policy-rego` → Reading a marker). +- **A marker can opt out of clearing.** A registry entry with `clearable: false` is never offered: the confirm page lists only the markers the person can clear, and a commit that names a non-clearable one is refused. When nothing in the session can be cleared — no markers, or only non-clearable ones — the request itself answers so, in the tool result, without minting a link: nobody is sent through a fresh sign-in to an empty page. The answer names no markers; which ones are held is only ever shown after the sign-in. When you know a marker is non-clearable (check `dtwo-get-marker` / `dtwo-list-markers`), say so when the person asks about it; the reader's deny reason then gives the time remaining and says the safeguard cannot be disabled early (see `dtwo-policy-rego` → Reading a marker). - **Markers and intent clear separately.** `disable_safeguards` offers each live clearable marker in scope once, as a safeguard showing its display name, registry description and time remaining. Disabling it clears the marker under every policy that wrote it, so name markers well (`displayName`, `description`): that is all the person sees when deciding. `clear_intent` only ever offers the platform-captured intent. An approved marker clear can never drop the intent, and the reverse holds too. - **It is armed per gateway.** Clearing requires the `gateway.session_control` block in the gateway config, and that block should say so explicitly — `clearing: {enabled: true}` (see `dtwo-gateway-config`). Left implicit it arms only while intent capture is on and parks inert otherwise — a shape that leaves marker policies enforcing with no targeted way out of a marker. Unavailable looks two different ways: where clearing is not armed the platform clear tool may be **absent from your tool list entirely**, so there is nothing to call and no refusal to read; where it is present but unarmed, the request returns a readable "not configured on this gateway" refusal that points at the TTL. Handle both rather than assuming the flow is available. It fails closed throughout: nothing is ever half-cleared. - **Confirm before requesting.** The request is a state change with a person on the other end of it — ask first, as you would before any state-changing call, and never fire one speculatively to "reset" a session. - **The request, the authorization and the commit are each recorded** in the gateway's event stream, so a clear is answerable after the fact: who approved it, when, and which instances went. -**Wording deny reasons for this.** A reader policy's `reason` is what the blocked person actually sees, so it should name the fast exit first and keep the TTL as the fallback — see the deny-reason guidance in `dtwo-policy-rego` → Session State & Markers → Reading a marker. +**Wording deny reasons for this.** A reader policy's `reason` is what the blocked person actually sees. It says three things, in this order: what triggered the restriction, the time remaining before it lifts, and — only when the marker is clearable and clearing is armed on the gateway — that they can ask to have the safeguard disabled; otherwise, that it cannot be disabled early. Example: "Posting to Slack is blocked because PII was seen in this session. Time remaining: 42 minutes. You can ask to have this safeguard disabled." The Rego that derives the time remaining and checks clearability is in `dtwo-policy-rego` → Session State & Markers → Reading a marker. ### Verifying a marker pipeline @@ -543,7 +562,7 @@ Markers can't be verified the way a single policy can — there is no tool to re 3. **Then exercise the reader** (as the same user). Confirm the reader's guarded tool now denies (or transforms) as intended. The marker stays active until its TTL expires. 4. **Confirm the negative case with a clean marker.** Use a **short TTL and wait for it to expire**, test as a **different user** who hasn't triggered the writer, or — on a gateway with clearing armed — **request a clear and approve it** (see Clearing a marker). Then the reader's tool should succeed, proving it blocks only when the marker is active. Reopening the session as the *same* user does **not** clear the marker (tenant+user scope), so that is not a valid negative test. -**Tip — validate with a short TTL.** A production-length TTL (say an hour) makes iterating painful: a marker stamped in one test stays set for that user until it expires and masks the next attempt. During validation, set the writer's `writableKeySchema.ttlSeconds` short (e.g. 30–60s) so it clears on its own between iterations. (Since the floor isn't enforced, a short `ttlSeconds` deploys regardless; set the marker's `minimumTtlSeconds` to match via `dtwo-update-marker` so the registry still reflects intent.) Once validated, raise the writer's `ttlSeconds` to the production length with `dtwo-update-policy` (and `minimumTtlSeconds` to match), then republish/redeploy. (A short TTL is about **iterating quickly**, not about the only way out of a marker — approving a clear resets one on demand, but it takes a browser round trip each time, so the short TTL is still the better loop for repeated tests.) +**Tip — validate with a short TTL.** A production-length TTL (say an hour) makes iterating painful: a marker stamped in one test stays set for that user until it expires and masks the next attempt. During validation, set the writer's `writableKeySchema.ttlSeconds` short (e.g. 30–60s) so it clears on its own between iterations. (Since the floor isn't enforced, a short `ttlSeconds` deploys regardless; set the marker's `minimumTtlSeconds` to match via `dtwo-update-marker` so the registry still reflects intent — and so the reader's deny reason, which derives its time remaining from the registry value, shows the right number.) Once validated, raise the writer's `ttlSeconds` to the production length with `dtwo-update-policy` (and `minimumTtlSeconds` to match), then republish/redeploy. (A short TTL is about **iterating quickly**, not about the only way out of a marker — approving a clear resets one on demand, but it takes a browser round trip each time, so the short TTL is still the better loop for repeated tests.) Watch for these: @@ -558,7 +577,7 @@ Skipping a step makes the next deploy fail (a policy still claims to write a mar 1. Update/remove the **writer policy** so it no longer references the marker in `writableKeySchema`; redeploy so the write contract leaves the bundle. 2. Delete any **intent/marker compatibility** rows that reference the marker (only relevant when intent tools are enabled — `dtwo-delete-intent-compatibility`); redeploy. -3. `dtwo-delete-marker` — nothing references it now. (`dtwo-delete-marker` does **not** currently check for policy references, so it can leave the bundle inconsistent if you skip step 1.) +3. `dtwo-delete-marker` — nothing references it now. The delete is refused, naming each blocking row, while a draft policy's `writableKeySchema`, a published version a pipeline step still selects, or a compatibility row references the marker; so if it refuses, you skipped a step above. ### Marker constraints today @@ -619,11 +638,11 @@ If a marker should block switching into a given intent, register a compatibility ``` dtwo-create-intent-compatibility( intentUid = , - excludedMarkerUid = + excludedMarkerUid = ) ``` -Example: once `marker:acme:pii_detected` is set, a `set_intent` to `incident_response` is blocked — the session already touched sensitive data. +Example: once `marker:acme:no_slack_after_pii` is set, a `set_intent` to `incident_response` is blocked — the session already touched sensitive data. **Set-time enforcement only.** The check runs at `set_intent` time. A marker raised *after* an intent is set does **not** retroactively invalidate the current intent. Markers accumulate; intents are validated at the decision point. Tell users this plainly so they don't design around a symmetric re-check that doesn't exist. @@ -641,13 +660,13 @@ The vocabulary you register is not only a management surface: it is shipped into "transitions_from": ["acme:review"] } ], "markers": [ - { "id": "marker:acme:pii_detected", "description": "…", + { "id": "marker:acme:no_slack_after_pii", "description": "…", "minimum_ttl_seconds": 3600, "clearable": true, - "display_name": "No email after PII" } + "display_name": "No Slack after PII" } ], "compatibility": [ - { "intent": "acme:deploy", "excluded_marker": "marker:acme:pii_detected" } + { "intent": "acme:deploy", "excluded_marker": "marker:acme:no_slack_after_pii" } ] } ``` diff --git a/dtwo/skills/dtwo-policy-rego/SKILL.md b/dtwo/skills/dtwo-policy-rego/SKILL.md index f53d5f4..8f2429f 100644 --- a/dtwo/skills/dtwo-policy-rego/SKILL.md +++ b/dtwo/skills/dtwo-policy-rego/SKILL.md @@ -885,9 +885,11 @@ This lets you compose small single-purpose policies that signal to each other wi - **Writer policy** — inspects the current call (arguments, response text, identity, whatever) and stamps a marker when a condition is met. - **Reader policy** — checks whether a marker is set and allows/denies/transforms accordingly. The writer and reader can attach to different tools, different directions, and different upstream servers. +To the person in the session, the marker together with its writer and reader is one **safeguard**: use that word in anything they read (deny reasons, the marker's display name and description) and keep "marker" for Rego, the registry and the admin. One marker is read by one reader policy, and one reader gates one tool or action — a trigger that should restrict several things writes one marker per restricted tool (see One trigger, several restrictions below), so each can be disabled on its own. + Start with the simplest shape — a **boolean flag** whose *presence* is the whole signal (the PII example below). Value-carrying markers (counters, structured payloads) are more advanced and rarely needed; a self-incrementing counter, for instance, has to read its own prior value and re-emit every call, which keeps refreshing the TTL. -Registering the marker vocabulary, attaching the `writableKeySchema`, and deploying are lifecycle operations owned by the companion `dtwo-gateway-policy` instructions (Managing Markers). This section covers only the **Rego**. +Registering the marker vocabulary, attaching the `writableKeySchema`, and deploying are lifecycle operations owned by the companion `dtwo-gateway-policy` instructions (Managing Markers). This section covers only the **Rego**. Two numbers in that lifecycle are the user's to give, not yours to pick: the TTL (`minimumTtlSeconds` on the marker, `ttlSeconds` on the writer's schema) and whether the marker is `clearable`. If nobody has asked the user yet, ask before you write the schema. ### Marker vs. general session key @@ -931,8 +933,9 @@ _pii_email_found if { regex.match(_email_pattern, text) } -# Stamp the marker when PII is observed in the response -session_writes["marker:acme:pii_detected"] := marker_value if { +# Stamp the marker when PII is observed in the response. `marked_at` is what +# the reader turns into the time remaining it shows the person. +session_writes["marker:acme:no_slack_after_pii"] := marker_value if { _pii_email_found marker_value := { "marked_at": time.now_ns(), @@ -943,6 +946,15 @@ session_writes["marker:acme:pii_detected"] := marker_value if { A writer is usually a `default allow := true` policy — it observes and stamps, it does not block. (A single policy *can* both deny and write, but prefer separate concerns.) +**One trigger, several restrictions.** When the same event should restrict more than one thing, the writer emits one key per restriction — the same value, one `session_writes` rule each — and declares every key in its `writableKeySchema` (a write to an undeclared key is dropped; the two-entry JSON under Markers that grant → One read, two markers shows the shape). Each key has its own reader policy, so the person can get one restriction disabled while the others stand. Do not collapse this into one reader that gates a set of tools on one key: that reads as tidier Rego, but it means the person can only get all of those tools back at once. One restricted tool, one marker, one reader: + +```rego +_value := {"marked_at": time.now_ns(), "source_action": input.resource.name} + +session_writes["marker:acme:no_slack_after_pii"] := _value if { _pii_email_found } +session_writes["marker:acme:no_email_after_pii"] := _value if { _pii_email_found } +``` + **The write only happens when the trigger matches.** The `if { ... }` body gates the write like any other rule, so it uses the same request-matching as the rest of this skill: compare tool names case-insensitively (`lower(input.resource.name) == "-"`, with the server-name prefix), read args via `object.get(input.payload.args, ...)`, and inspect response content via `input.payload.text` on egress. A writer whose trigger never matches (wrong case, missing server prefix, wrong direction) stamps nothing — and there's no error, just an absent marker. Confirm the exact tool name and payload shape with the dump-input technique (see Debugging Policies) before relying on the trigger. **Silent-drop trap.** Because the schema is strict (`additionalProperties: false`), slipping any undeclared field into the value fails validation — most commonly a `ttl`/`ttlSeconds`, which belongs on the schema (per the rule above), not the value. With the default `onDrop: "drop"` that failure is **silent**: the marker never lands and readers never see it. If a marker mysteriously isn't being read, check the written value against the schema first. @@ -964,36 +976,79 @@ session_writes["marker:acme:jira_access"] := { ### Reading a marker -Active session state is exposed to a policy at `input.context.session.policies`, shaped as `policies[][] = ` — an outer object keyed by the UID of the policy that wrote each key, and under each writer the keys it wrote (a marker key `marker::` maps to the object the writer emitted). A marker is stored under the **writer policy's UID**, so the read walks all writer slots and treats the key as truthy if any writer set it: +Active session state is exposed to a policy at `input.context.session.policies`, shaped as `policies[][] = ` — an outer object keyed by the UID of the policy that wrote each key, and under each writer the keys it wrote (a marker key `marker::` maps to the object the writer emitted). A marker is stored under the **writer policy's UID**, so the read walks all writer slots and treats the key as truthy if any writer set it. + +A reader does two jobs: the gate (deny while the marker is present) and the deny reason the person reads. The reason says what triggered the restriction, how long it has left, and whether they can ask to have it disabled. The writer's `marked_at` supplies the clock; the registry (`data.dtwo.intent_registry`, see Reading the registry) supplies the TTL and the `clearable` flag; `data.dtwo.lib.intent_match.clearing_armed` says whether disabling exists on this gateway at all: ```rego package acme.ingress.pii_gate import future.keywords.if +import future.keywords.in default allow := true -_pii_active if { +_key := "marker:acme:no_slack_after_pii" + +_active if { some writer_uid - input.context.session.policies[writer_uid]["marker:acme:pii_detected"] + input.context.session.policies[writer_uid][_key] +} + +# Registry lookups. Each has a default, so an unregistered key or an older +# bundle never leaves `reason` undefined (the call would deny with no message). +default _ttl_seconds := 3600 # keep equal to the registered minimumTtlSeconds + +_ttl_seconds := m.minimum_ttl_seconds if { + some m in data.dtwo.intent_registry.markers + m.id == _key } -# Block outbound Slack sends once PII was seen anywhere in this session +# Offer disabling only when the marker is clearable AND clearing is armed on this gateway. +default _can_disable := false + +_can_disable if { + some m in data.dtwo.intent_registry.markers + m.id == _key + m.clearable + data.dtwo.lib.intent_match.clearing_armed +} + +# Newest write across writers: every write restarts the TTL, so it is the clock's anchor. +_marked_at := max([t | + some writer_uid + v := input.context.session.policies[writer_uid][_key] + t := object.get(v, "marked_at", 0) + t > 0 +]) + +_minutes_left := ceil(max([_ttl_seconds - ((time.now_ns() - _marked_at) / 1000000000), 0]) / 60) + +default _remaining := "It lifts when this safeguard expires." + +_remaining := sprintf("Time remaining: %v minutes.", [_minutes_left]) if { _minutes_left } + +default _exit := "This safeguard cannot be disabled early." + +_exit := "You can ask to have this safeguard disabled." if { _can_disable } + allow := false if { lower(input.resource.name) == "slack-mcp-slack-send-message" - _pii_active + _active } -reason := "PII was detected earlier in this session, so outbound Slack sends are blocked. To lift the block now, ask your agent to disable this safeguard and approve it in your browser; otherwise it lifts when it expires." if { +reason := sprintf("Posting to Slack is blocked because PII was seen in this session. %s %s", [_remaining, _exit]) if { lower(input.resource.name) == "slack-mcp-slack-send-message" - _pii_active + _active } ``` - The walk-all-writers pattern (`some writer_uid; input.context.session.policies[writer_uid][key]`) is "present under *any* writer is truthy." To trust only a specific writer, filter on `writer_uid == ""`. - **This pattern is for reading *marker* keys only.** Do **not** use it — or any direct `input.context.session.policies` read — to read the platform **intent** (see Intent-capture policies → Reading the session intent). "Present under any writer" is exactly wrong for intent: a tenant policy could stamp an intent-shaped value under its own writer slot and a walk-all-writers read would honour it, spoofing the session intent. Read intent only through the platform helper, which is pinned to the trusted intent-capture slot. -- **Deny reasons are user-visible — give the path out, fastest first.** State what happened and what the person can do about it. The person knows a marker as a **safeguard**, so the reason says "safeguard", not "marker". A safeguard has two exits, and they are not equal: **disabling it with human approval** lifts it in under a minute, and **TTL expiry** lifts it eventually. Offer disabling first and keep TTL as the fallback — a reason that mentions only the TTL tells someone to wait an hour for something they could have resolved immediately. Phrase it so it reads correctly either way ("ask your agent to disable this safeguard … otherwise it lifts when it expires"). Address the ask to the person's agent, not the person: the only way to start is the platform `disable_safeguards` tool call, so someone reading your reason in a log or the Hub has nothing to click. Keep the TTL half of the sentence even so — clearing is armed per gateway, and where it is not armed the clear tool may be missing from the agent's tool list altogether, or present but answering with a readable "not configured on this gateway" refusal; either way the TTL is the exit that still holds. Word it as a recovery the person authorizes, not as a way around the decision — the clear needs their explicit approval in a browser precisely so an agent cannot use it to shrug off a block (see `dtwo-gateway-policy` → Clearing a marker for the mechanics and the reasoning). -- **A non-clearable marker has one exit, so say only that.** If the marker's registry entry has `clearable: false` (check `dtwo-get-marker` or `dtwo-list-markers`; the default is `true`), the human-approved clear is refused for it and the clear request is answered without a sign-in link, so a reason that offers the clear promises something the person cannot get. Write the TTL as the whole answer: "… blocked until this safeguard expires". Don't state a *time remaining*: a policy sees only the marker's value, not when it was written or when it expires, and a hard-coded TTL in the policy can drift from the registry. The flag is part of the vocabulary entry every reader is written against, so treat it as a decision made at registration, not a toggle. +- **Deny reasons are user-visible — say three things, in this order.** *What triggered it*, *the time remaining*, *the exit*: "Posting to Slack is blocked because PII was seen in this session. Time remaining: 42 minutes. You can ask to have this safeguard disabled." The person knows a marker as a **safeguard**, so the reason says "safeguard", never "marker", and names no tool, claim or key. +- **What triggered it** is the restriction and its cause in the person's words. The registered `description` is written to be exactly this sentence, so you can take it from the registry instead of typing it (see Reading the registry); then the deny reason and the disable page say the same thing. +- **Time remaining is derived, not read.** A policy sees only the marker's value — not when it was written or when it expires. So the writer stamps `marked_at` (`time.now_ns()`, kept an integer) and the reader subtracts it from now and from the registered `minimum_ttl_seconds` — which is why the writer's `ttlSeconds` must equal that registry value. Take the newest `marked_at` across writers: every write restarts the TTL. When the value carries no `marked_at` (a writer you don't own, a hand-written value), fall back to "it lifts when this safeguard expires" rather than guessing, and never hard-code the TTL in the reader: it drifts from the registry. +- **Offer disabling only where it exists.** Say "you can ask to have this safeguard disabled" only when the registry entry is `clearable` **and** `data.dtwo.lib.intent_match.clearing_armed` is true — clearing is armed per gateway, and where it is not, the disable tool is missing from the agent's tool list or answers with a "not configured on this gateway" refusal, so the sentence would promise something the person cannot get. Otherwise say the safeguard cannot be disabled early; the time remaining is then the whole answer. The helper is defined in every current bundle; on an older bundle it is undefined, which the `default` handles (conservatively: no offer). Word it as something the person asks for — their agent starts it with the platform `disable_safeguards` tool, and someone reading the reason in a log or the Hub has nothing to click. Never offer disabling as a way around the decision: the person approves it in a browser precisely so an agent cannot shrug off a block (see `dtwo-gateway-policy` → Clearing a marker). - **Avoid "start a new session"** — marker state is scoped to tenant + user and survives reconnecting, so a new session for the same user won't clear it. - **Never build a reason out of the session intent.** A reason may say *that* the current intent failed a gate; it must not carry the intent itself — do not interpolate `current_intent(input)` or its `description` into `reason` or into a transform. Name the rule that fired instead. Full rule under Intent-capture policies → Reading the session intent. @@ -1001,47 +1056,26 @@ reason := "PII was detected earlier in this session, so outbound Slack sends are The marker (and, where enabled, intent) vocabulary is shipped into every gateway's policy bundle as OPA base data at `data.dtwo.intent_registry`, in the same atomic deploy as the Rego — so a policy can consult the registry at decision time instead of hard-coding what it knows about a key. The document is always defined and always fully shaped (empty arrays for an empty tenant), so a read needs no "registry missing" branch; a *specific* entry can of course be absent. -The reader from **Reading a marker** above, with its message sourced from the registry instead of a string pasted into the policy: +The reader under **Reading a marker** already consults the registry for `minimum_ttl_seconds` and `clearable`. The same lookup can supply the first sentence of the reason, so the deny reason and the disable page say the same thing — the registered `description` is written for exactly that (see `dtwo-gateway-policy` → Registering a marker): ```rego -package acme.ingress.pii_gate - -import future.keywords.if -import future.keywords.in - -default allow := true - -_key := "marker:acme:pii_detected" - -_pii_active if { - some writer_uid - input.context.session.policies[writer_uid][_key] -} - -# The registered description, so the message tracks the registry rather -# than a string pasted into the policy. The `default` is load-bearing: +# Registered description as the first sentence. The `default` is load-bearing: # without it an unregistered key leaves the whole `reason` undefined. -default _registered_description := "a sensitive-data marker is set on this session" +default _what := "Posting to Slack is blocked because PII was seen in this session." -_registered_description := d if { +_what := m.description if { some m in data.dtwo.intent_registry.markers m.id == _key - d := m.description -} - -allow := false if { - lower(input.resource.name) == "slack-mcp-slack-send-message" - _pii_active } -reason := sprintf("Blocked: %s. To lift the block now, ask your agent to disable this safeguard and approve it in your browser; otherwise it lifts when it expires.", [_registered_description]) if { +reason := sprintf("%s %s %s", [_what, _remaining, _exit]) if { lower(input.resource.name) == "slack-mcp-slack-send-message" - _pii_active + _active } ``` - Entries are keyed by **FQID** (`id`) — `marker::` for markers, `:` for intents. No UIDs appear in the data document. -- `markers[]` carries `id`, `description`, `minimum_ttl_seconds`, `clearable` (`false`: the human-approved clear never offers it; see Reading a marker) and, when set, `display_name`; `intents[]` carries `id`, `description`, and optionally `aliases` / `transitions_to` / `transitions_from`; `compatibility[]` carries `intent` and `excluded_marker`. A transitions field is **omitted when unrestricted** and `[]` when locked — treat missing as "no restriction". +- `markers[]` carries `id`, `description`, `minimum_ttl_seconds` (what the reader's time remaining is computed against), `clearable` (`false`: the human-approved clear never offers it; see Reading a marker) and, when set, `display_name`; `intents[]` carries `id`, `description`, and optionally `aliases` / `transitions_to` / `transitions_from`; `compatibility[]` carries `intent` and `excluded_marker`. A transitions field is **omitted when unrestricted** and `[]` when locked — treat missing as "no restriction". - Registry text is authored by your own admins, so it is safe to surface in a reason — unlike the session intent, which must never be echoed (see Reading the session intent). - **Give any registry-derived string a fallback** — the `default` line above is not decoration. A lookup that finds nothing is *undefined*, not empty, and an undefined term makes the whole `reason` undefined: the call still denies, but the person sees no message at all. A `default` on the lookup rule fixes it, as does a second `reason` rule that omits the lookup. - Registry edits reach the gateway on its **next policy deploy**, not immediately. @@ -1065,9 +1099,9 @@ that unlocks something makes its writer an authorization decision point: ```rego # Deny direction — "did anyone see PII?" Any writer may answer. -_pii_active if { +_active if { some writer_uid - input.context.session.policies[writer_uid]["marker:acme:pii_detected"] + input.context.session.policies[writer_uid]["marker:acme:no_slack_after_pii"] } # Allow direction — "did THE reviewer policy record this?" Exactly one writer may answer. @@ -1266,7 +1300,8 @@ Marker-key *shape* is validated server-side (the backend on save, and at deploy) - **`time.now_ns()` must stay an integer.** Use `time.now_ns()` raw for timestamp fields typed `integer` in the schema. Dividing in Rego (e.g. `time.now_ns() / 1000000`) produces a **float**, which fails a `"type": "integer"` schema — and with `onDrop: "deny_request"` that silently-authored bug will block the tool call. - **Match the key exactly.** The `session_writes` key, the `writableKeySchema` `name`, and the registered marker FQID must all be the identical `marker::` string. A mismatch drops the write. - **Multiple writers land in separate slots.** If two policies declare and emit the same key, each write lands under its own writer UID; the walk-all-writers read finds either. Prefer one canonical writer per marker. -- **Reads fail open on absent state.** If `input.context.session.policies` is missing or the marker was never written, the `_pii_active` helper simply doesn't match — the reader allows. Structure high-sensitivity gates so the *presence* of the marker is what denies, not its absence (that's the intended semantics: no signal → nothing to block). A marker that *grants* a capability inverts this and must read fail-closed instead — see **Markers that grant**. +- **Reads fail open on absent state.** If `input.context.session.policies` is missing or the marker was never written, the `_active` helper simply doesn't match — the reader allows. Structure high-sensitivity gates so the *presence* of the marker is what denies, not its absence (that's the intended semantics: no signal → nothing to block). A marker that *grants* a capability inverts this and must read fail-closed instead — see **Markers that grant**. +- **Time arithmetic makes floats.** `(time.now_ns() - marked_at) / 1000000000` is a float; wrap the result in `ceil` or `floor` before `sprintf` and print it with `%v`. Never write a derived time back into an `integer` schema field — the stored `marked_at` stays the raw `time.now_ns()`. ## Intent-capture policies (conditional — feature-gated) From 0992de07d263a7d32154a9b96bc6b66321a42055 Mon Sep 17 00:00:00 2001 From: Paul Reilly Date: Tue, 27 Oct 2026 12:39:01 -0400 Subject: [PATCH 2/3] Reader example: tolerate marker values without a usable marked_at A writer that emits a bare flag, or a value whose marked_at is not a number, must get the no-timestamp fallback rather than leave the reason undefined, so the time-remaining comprehension now checks the value is an object and the timestamp is numeric before doing arithmetic. Co-Authored-By: Claude Fable 5.1 --- dtwo/skills/dtwo-policy-rego/SKILL.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dtwo/skills/dtwo-policy-rego/SKILL.md b/dtwo/skills/dtwo-policy-rego/SKILL.md index 8f2429f..65b68db 100644 --- a/dtwo/skills/dtwo-policy-rego/SKILL.md +++ b/dtwo/skills/dtwo-policy-rego/SKILL.md @@ -1014,11 +1014,14 @@ _can_disable if { data.dtwo.lib.intent_match.clearing_armed } -# Newest write across writers: every write restarts the TTL, so it is the clock's anchor. +# Newest write across writers: every write restarts the TTL, so it is the clock's +# anchor. Tolerate values other writers may have left under the key. _marked_at := max([t | some writer_uid v := input.context.session.policies[writer_uid][_key] + is_object(v) t := object.get(v, "marked_at", 0) + is_number(t) t > 0 ]) From 3d4586dd780b322ca2bd1863a88db2fed27289d9 Mon Sep 17 00:00:00 2001 From: Paul Reilly Date: Tue, 27 Oct 2026 12:39:18 -0400 Subject: [PATCH 3/3] Plugin version 1.3.2 (main shipped 1.3.1 while this PR was open) --- dtwo/.claude-plugin/plugin.json | 2 +- dtwo/.codex-plugin/plugin.json | 2 +- dtwo/.cursor-plugin/plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dtwo/.claude-plugin/plugin.json b/dtwo/.claude-plugin/plugin.json index 642a62c..8f84a9d 100644 --- a/dtwo/.claude-plugin/plugin.json +++ b/dtwo/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.3.1", + "version": "1.3.2", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo", diff --git a/dtwo/.codex-plugin/plugin.json b/dtwo/.codex-plugin/plugin.json index 99c7fe2..5ad050d 100644 --- a/dtwo/.codex-plugin/plugin.json +++ b/dtwo/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.3.1", + "version": "1.3.2", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo", diff --git a/dtwo/.cursor-plugin/plugin.json b/dtwo/.cursor-plugin/plugin.json index 0f87155..4567c69 100644 --- a/dtwo/.cursor-plugin/plugin.json +++ b/dtwo/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.3.1", + "version": "1.3.2", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo",