Document the human-approved clear path in the policy skills - #40
Conversation
The marker guidance told authors that a marker's TTL is the only way out of a block, so a policy author following it wrote deny reasons telling people to wait an hour for something they could resolve in under a minute. The gateway-config skill already documented `gateway.session_control`, so the three skills disagreed with each other. - `dtwo-policy-rego`: the worked example's deny reason now offers the clear first and keeps the TTL as the fallback, and the deny-reason guidance states that rule (phrased so the sentence still reads correctly on a gateway where clearing is not armed). - `dtwo-gateway-policy`: new "Clearing a marker" section covering what the agent can and cannot do — it requests, a person approves in a browser — plus the argument-less tools, the separate marker and intent scopes, per-gateway arming and its refusal, and same-identity approval. The stale "no clear tool" claims are corrected, and the short-TTL testing guidance now reads as an iteration tip rather than the only exit. - Both skills: a policy may decide on the session intent but must never return it to the agent. - `dtwo-gateway-policy`: document `data.dtwo.intent_registry`, with a worked Rego read in `dtwo-policy-rego` (validated with `opa eval`, including the undefined-lookup case). `dtwo-gateway-config`'s coverage is generated from the schema artifact and needed no change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The clearing section described the shape — an agent requests, a person approves — and every mechanical constraint that follows from it, but never the reason, which left the constraints reading as friction rather than as the point. The behavioural guidance was worse: it framed asking first as etiquette rather than as the boundary it is. Now stated plainly: a marker is worth exactly as much as the agent's inability to remove it, so the human step is the control, and what the ceremony produces is evidence that a named person, freshly authenticated, authorized this clear of these specific values. Plus the rule that follows for an agent — never offer a clear as a way around a correct block; the person judges whether the state has outlived its purpose, and every request is recorded either way. The same reasoning now travels with the two shorter statements a reader may hit on their own: the marker-constraints bullet and the deny-reason guidance in dtwo-policy-rego (word it as a recovery the person authorizes, not as a bypass). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- aliases is omitted when the entry has none; the omitted-when-unrestricted rule belongs to the transitions fields only. - The registry-as-policy-data section sits after the gated intent material, so it now says up front that it is not itself gated. - The markers intro still implied TTL was a marker's only end of life. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The vendored schema artifact predated the session-control changes in the product repo, so the config skill documented `client_id` as required and knew nothing of `redirect_uri`. On a gateway that authenticates against Dtwo's own IdP that is backwards: the platform provisions the ceremony application per organization and the deploy renders its client id, so the field should be left unset, and only a gateway trusting a customer-run IdP needs to set one. Both vendored files are refreshed from the same product-repo revision, per the resolution of the bundle/artifact drift defect — refreshing the artifact alone would have reintroduced it, with `session_control.redirect_uri` strict-rejected by the older bundle and `SESSION_CONTROL_REDIRECT_URI` accepted inside `gateway.advanced`. - schema artifact and validator bundle re-vendored together; all three pins bumped (artifact sha, bundle sha, bundle version 2.0.0 -> 3.0.0). The bundle's type surface is unchanged, so the harness's shape assumptions still hold. - digest regenerated: `client_id` now optional with the Dtwo-authentication guidance, `redirect_uri` documented, `SESSION_CONTROL_REDIRECT_URI` listed as reserved, and two new cross-field constraints. - `generate-schema-digest.mjs` renders two upstream OAuth fields the artifact gained, `token_endpoint_auth_method` and `omit_resource`. Both are user-audience, so the coverage gate failed until they were added. - incidental catch-up carried by the refresh: the CGNAT note on `allowed_networks`, and five newly platform-managed reserved keys. Verified: digest --check in sync, 177 harness tests pass, biome clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lback Two review findings against the gateway's own state. The federated name is hyphenated throughout — the gateway's tool table has `dtwo-platform-intent-clear-markers`, and only `original_name` keeps the underscore. The skill quoted the underscored form, which resolves to no tool. The worked registry example built `reason` from a registry lookup with no `default`, which is exactly the trap the bullet below it warns about: on an unregistered key the lookup is undefined, so the whole `reason` is undefined and the call denies with no message. Confirmed on OPA 1.18 — the miss case returned `allow: false` with `reason` absent. Examples get copied more readily than the prose under them is read, so the `default` belongs in the snippet; the bullet now explains the line rather than asking for one that was not there. Verified: both branches of the example re-evaluated from the SKILL.md source (registry hit and miss now both produce a reason), opa check clean, 177 harness tests pass, digest in sync. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Confirm before requesting" bullet told the reader to ask first "the same way you would before `set_intent`". That bullet lives in Managing Markers, which the skill is emphatic is available on every gateway, whereas `set_intent` is not: a markers-only deployment arms clearing with `clearing.enabled: true` and intent capture off, so the analogy points at a tool that reader does not have. Compare against any state-changing call instead. The neighbouring mentions stay — they name the platform tool surface and draw the markers-vs-intent distinction, which is orientation rather than presenting the intent workflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The product repo changed what it recommends for the `session_control` block.
An empty block means different things depending on `gateway.intent.enabled` —
it arms clearing with capture on, and parks inert with capture off — so the
guidance now prefers writing `clearing: {enabled: true}` rather than leaving
the block empty and deferring its meaning to another block. An empty block
stays fully supported and arms exactly as before; upstream also gained an
advisory when a block is present but arms nothing.
Artifact and validator bundle re-vendored together from the same revision, as
before. Only the two SHA pins move — the bundle stays at 3.0.0, since this is
metadata rather than a validation change. The digest diff is limited to the
two guidance strings and the embedded artifact sha; no other field moved.
Verified: digest --check in sync, 177 harness tests pass, biome clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The re-vendored digest now carries d2's rewritten guidance — write clearing.enabled: true whenever you want clearing, rather than leaving it to gateway.intent.enabled — but the clearing section still described a bare block as sufficient. An implicit block arms only while intent capture is on and parks inert otherwise, which is exactly the shape that leaves marker policies enforcing with no targeted way out of a marker. Also documents the oauth field-order allowlist in the digest generator: a field added upstream is dropped rather than appended, and the script's own coverage check is what makes that safe by refusing to write until the field is placed. Worth a comment, since hitting that error looks like a generator bug rather than the guardrail it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Ready for review — but please do not merge on approval. This vendors a Review away — the hold is only on the merge button. I'll follow up here once the source revision is out, and that comment is the green light. |
oschaaf
left a comment
There was a problem hiding this comment.
Led a panel review on this alongside dtwoai/d2#2036: one reviewer checked every behavioural claim in the two policy skills against the gateway source and the product repo, one checked vendoring/pins/harness/public hygiene, and I re-verified each finding before posting. Approving. Honouring the hold — merge is yours, and the button stays untouched until your green-light comment.
Verified true (customer-facing claims, against the pinned gateway release)
- Registry document: shape, field names, FQID-only keys,
transitions_*omitted-when-unrestricted /[]-when-locked, always fully shaped even for an empty tenant, shipped unconditionally in the same bundle as the Rego, mounted atdata.dtwo.intent_registry. The platform's own intent enforcement reads the same document. - Clear tools: argument-less, hyphenated wire name (the slugifier maps
_→-), forced fresh login (prompt=login+max_age), same-identity check, one-login-one-clear, TTL-filtered enumeration, multi-holder flag with "clearing only this one leaves it active",clear_intentoffers only the trusted-writer intent, marker/intent clears are disjoint by construction, all-or-nothing commit with rollback on drift, three audit events at mint/authorize/commit. - Arming semantics match the validator exactly.
data.dtwo.lib.intent_matchships unconditionally;current_intent/current_categoryare real rule names.- Both Rego snippets pass
opa check(incl.--strict,--v0-compatible,--v0-v1) and the deploy-time validator. The registry example evaluates with registry text when the entry exists and with thedefaultwhen it doesn't; removing thedefaultleavesreasonundefined whileallowstays false — so the "load-bearing" claim is literally true. - Vendoring: bundle rebuilt from the d2 PR head →
971e2d7c…ee16, byte-identical to the vendored file; schema artifact byte-identical;.d.mtsunchanged;VALIDATOR_BUNDLE_VERSION3.0.0 in both; digest--checkin sync and regen is a no-op; the allowlist coverage gate really does refuse to write when a field is missing; harness 177/177; reserved-key counts 21+11+28=60 with six additions and no removals;plugin.json-only bump is the repo convention. Hygiene grep of the artifact, digest and bundle is clean.
Asks (inline)
- "Handle the 'not configured' refusal" needs a second branch. The reserved server hosting
clear_markersis federated only when intent capture is on or clearing is armed. On the markers-only gateway with no/parked block — the very shape that bullet warns about — there is no clear tool in the list at all, so no request and no refusal. The agent has to handle tool absent too. - The deny-reason wording "ask for a session clear and approve it in your browser" presumes the reader is driving an assistant that has the platform tool and relays the link. That's the design (no human-initiated path exists), but it's worth stating once so authors know who "ask" is addressed to.
Nits (inline): "every request is recorded" is true once armed (the unarmed refusal returns before any event); "returns a URL and nothing else" is an instruction sentence plus the URL; "the one shape" undersells the no-block-at-all case.
On the hold — two notes for whoever lifts it:
- Re-verify both shas against d2#2036's merged head before merging here. Its review asks are README/runbook prose, not
meta()strings, so the shas should hold — but any later edit to a schema string inconfig.tsmoves both pins and silently un-pairs the PRs. - For the record: the validator behaviour this bundle carries (optional
client_id,redirect_uri, the twooauth_configrejections, the six new reserved keys) is already on d2mainand has been deploying since 09-03. What's genuinely gated on d2#2036 is prose agreement plus the new inert-block advisory. Nothing the skill tells a user to write would be rejected by today's deployed validator. The hold is still a fine call for keeping the two surfaces in step; just noting the gate is narrower than the body suggests. - The PR body attributes the 2.0.0→3.0.0 delta to two
oauth_configrejections. That's accurate for what d2 chose to version, but relative to the previously vendored bundle the harness rubric also newly rejects sixgateway.advancedkeys, newly accepts threesession_controlshapes, and stops strippingtoken_endpoint_auth_method/omit_resourceon round-trip. No fixture exercises any of that today (verified), so nothing flips — but a line listing the full delta would help a future bisect.
The "why the person is in the loop" paragraph is the best thing in this PR. It says the security property in plain words and then derives every constraint from it. Keep that.
| - **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. | ||
| - **Markers and intent clear separately.** `clear_markers` offers every live `marker:` instance in scope, and flags one that is held by more than one writer — clearing a single holder leaves the marker standing, so each holder is acknowledged on its own. `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, which is the one shape that leaves marker policies running with no targeted way out of a marker. Where it is not configured the request comes back with a readable "not configured on this gateway" refusal that points at the TTL instead — handle that answer rather than assuming the flow is available. It fails closed throughout: nothing is ever half-cleared. |
There was a problem hiding this comment.
Should-fix: "Where it is not configured the request comes back with a readable 'not configured on this gateway' refusal … handle that answer" is only reachable when the clear tool exists. The reserved server that hosts clear_markers is federated only when intent.enabled || clearingArmed (product repo: deploy.ts reservedServerFederated). On the markers-only gateway with no session_control block — or the parked empty block this same bullet describes — there is no clear tool in the agent's tool list, so there's nothing to request and no refusal ever appears. The refusal text (which does point at the TTL, verified) shows up on an intent-on gateway without a block, or after a runtime force-disarm.
Suggest: "Where it is not armed, the platform clear tool may be absent from your tool list entirely; where 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."
Tiny wording nit on the same line: "the one shape that leaves marker policies running with no targeted way out" — the no-block-at-all gateway is the same situation and the more common one; "a shape" or "the shape to watch for" reads truer.
|
|
||
| **Why the person is in the loop.** A marker is worth exactly as much as the agent's inability to remove it. If the agent a marker constrains could also lift it, the marker would constrain nothing — it would be a speed bump with a documented way around, and every policy built on markers would inherit that. So the human step is not a convenience tax on the flow; it **is** the control. What the ceremony produces is evidence: a named person, freshly authenticated at that moment, explicitly authorized *this* clear of *these* specific values. That is also why the flow declines to be convenient — no arguments, so the agent cannot choose the target; one interactive login per clear, so an approval cannot be batched, reused, or replayed; and the same identity as the caller, so it cannot be handed to whoever happens to be at the keyboard. | ||
|
|
||
| **What that means for how you use it.** Never offer a clear as a way around a policy decision. If a block is correct, the answer is to stop and explain it — not to reach for the clear. A clear is appropriate when the state has outlived its purpose: the condition that raised the marker has been dealt with, and **the person**, never the agent, judges that it has. An agent that reflexively requests a clear on every denial is doing the exact thing the human gate exists to prevent, and every request is recorded whether or not anyone approves it. |
There was a problem hiding this comment.
Nit: "every request is recorded whether or not anyone approves it" — true once clearing is armed (clear_minted at mint, clear_denied for too_many_pending_clears/unknown_operation, then clear_authorized / clear_committed). The unarmed refusal returns from the plugin before anything is minted and emits no event. "Once clearing is armed, every request is recorded …" keeps the sentence exact.
|
|
||
| A marker has two exits: its TTL expires, or a **person approves a clear**. The second one is the fast path, and it is the one your deny reasons should offer first — a marker set at the start of an hour-long TTL can otherwise block someone for the rest of that hour over a condition they have already dealt with. | ||
|
|
||
| **An agent can request a clear; it cannot complete one.** Requesting returns a URL and nothing else. The person opens it, signs in interactively at the identity provider (a fresh login, even if they are already signed in), picks what to clear from a list the gateway builds, and confirms. Everything after the request happens in a browser, authenticated as a human. |
There was a problem hiding this comment.
Nit: "Requesting returns a URL and nothing else" — the tool result is an instruction sentence plus the URL ("A human must approve this clear … Open the link below in a browser, sign in, and click Confirm — …\n\n"). The substance (a link, never a capability) is exactly right; "returns a link to open in a browser and nothing an agent can act on" would be both accurate and make the security point harder.
| - 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 == "<known-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 — explain what to do about the block (e.g. "wait for the marker TTL to expire"). 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. | ||
| - **Deny reasons are user-visible — give the path out, fastest first.** State what happened and what the person can do about it. A marker has two exits, and they are not equal: a **human-approved clear** lifts it in under a minute, and **TTL expiry** lifts it eventually. Offer the clear 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 for a session clear … otherwise it lifts when the marker expires"), because clearing is armed per gateway: where it is not configured, the clear request returns a readable "not configured on this gateway" refusal and the TTL half of your sentence 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). |
There was a problem hiding this comment.
Worth stating once, here or in the gateway-policy "Clearing a marker" section: "ask for a session clear" presumes the blocked person is working through an assistant that (a) has the platform clear tool federated, (b) will call it, and (c) relays the link. There's no human-initiated entry point — the only start is the MCP tool call, which needs the caller's gateway bearer — so a person reading this reason in a log or the Hub has nothing to click. That's the intended design (and matches the product repo's own wording), so not asking for a change to the sentence — just one clause so authors know who "ask" is addressed to, e.g. "ask your assistant to request a session clear".
| * with `EXPECTED_VALIDATOR_BUNDLE_VERSION` when the bundle is re-vendored. | ||
| */ | ||
| export const EXPECTED_VALIDATOR_BUNDLE_SHA256 = 'd5d07962a80a47e3ccc7a9550e6b56650321bed2d3a541d932c70f167d5c9bda'; | ||
| export const EXPECTED_VALIDATOR_BUNDLE_SHA256 = '971e2d7ccb7a2e28f35a899b510697d73d0606a0083825af7feead3b0e47ee16'; |
There was a problem hiding this comment.
Verified: rebuilt from the d2 PR head (esbuild 0.28.1, node 24, macOS-arm64) → 971e2d7c…ee16, cmp byte-identical to the vendored file; the schema artifact is byte-identical too and the .d.mts is unchanged vs main. One ask for whoever lifts the hold: re-check both shas against d2#2036's merged head first. Its open review asks are README/runbook prose (not meta() strings), so they shouldn't move — but any late edit to a schema description/rationale in config.ts changes both artifacts and silently un-pairs the two PRs.
| // Ordered allowlist: a field newly added to the artifact is DROPPED here | ||
| // rather than appended blindly. That is safe only because the coverage check | ||
| // at the end of this script refuses to write the digest until the field is | ||
| // placed deliberately — if you hit that error, add the name here. |
There was a problem hiding this comment.
Confirmed the claim in this comment: removing 'omit_resource' from fieldOrder and running --check fails with "Digest coverage check failed — 1 item(s) missing … target sotw.oauth_config.omit_resource … is not rendered in the digest" before anything is written. Good comment to have — hitting that gate really does read like a generator bug the first time.
Review asks from #40, verified against the gateway source. - The "handle the not-configured refusal" advice only holds when the tool exists. The reserved server hosting clear_markers is federated only when intent capture is on or clearing is armed, so on the markers-only gateway with no (or a parked) session_control block — the very shape that bullet warns about — there is no clear tool in the list, no request, and no refusal. Both skills now say unavailable looks two ways and to handle each. The deny reason itself needed no change: "otherwise it lifts when the marker expires" stays true when no tool exists, which is worth keeping deliberate. - The deny-reason example now addresses the ask to the reader's agent. There is no human-initiated entry point — the only start is the platform tool call — so someone reading the reason in a log or the Hub has nothing to click. "agent" not "assistant": the skills already use "the agent" nineteen times and would otherwise give one actor two names. - Two precision fixes: every request is recorded once clearing is ARMED (the unarmed refusal returns before anything is minted, so it emits no event), and the request returns a link to open in a browser rather than "a URL and nothing else" — the result is an instruction plus the link, and naming what an agent cannot act on makes the security point harder. - Arming guidance points at the explicit clearing.enabled, matching the regenerated config digest. Verified: both Rego examples pass opa check and the registry example still evaluates to the reworded reason (including the missing-entry default), digest --check in sync, harness 177/177. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both asks and all three nits taken in e546e07, verified against the gateway source rather than accepted on description. The tool-absent branch was the real find. You're right that the refusal is only reachable when the tool exists — the reserved server is federated only when intent capture is on or clearing is armed, so on the markers-only gateway with no (or parked) block there is nothing to call. Both skills now say unavailable looks two ways and to handle each. Worth noting the deny reason itself needed no change: "otherwise it lifts when the marker expires" stays true when no tool exists, so an author following the phrasing rule produces a correct message either way. That was luck rather than design, so I've kept the rule prominent. On "who is ask addressed to": taken, and the example now says "ask your agent to request a session clear". Not "assistant" — the skills already use "the agent" nineteen times and zero "assistant", so introducing a second name for the same actor in a document about what the agent can and cannot do would cost more than the precision gained. Your delta point turned out stronger than stated. I probed both vendored bundles directly instead of reasoning from the version bump: the old bundle rejects a And you were right about the hold being narrower than I framed it. The validator behaviour here has been on the product repo's Digest |
|
Pairing check against the merged product-repo head — both files still paired. The product-repo change this vendors is merged. Rebuilding on that merged head and comparing byte-for-byte against what's vendored here:
Expected, since the review round there touched README, runbook, docs, tests and a workflow comment — not any schema Still held until the release carrying that change reaches production. Two things to redo at that point rather than trusting this comment:
|
|
Friendly suggestion, not a review ask: give the new Otto raised a good question after the review — could the skill changes here be exercised by the harness, and does CI run it? Here's where things stand, so you can decide whether it's worth a few minutes on this PR or a follow-up. What the harness can and can't reach in this PR
Two fixtures that would pin it (dropping into # session-control-markers-only.yaml
id: session-control-markers-only
tier: aspirational # promote to required once it passes at 100%
tags: [session_control, clearing, markers_only]
user_prompt: |
Our gateway uses Dtwo authentication (Auth0 tenant acme.us.auth0.com,
audience https://api.acme.com). We run marker-writing policies but do
NOT use intent capture. Enable human-approved clearing so a person can
lift a marker without waiting for its TTL. One bearer-token MCP server
at https://mcp.acme.com/mcp.
followups:
- match: /audience/i
reply: "https://api.acme.com"
expect:
must_validate: true
required_paths:
- gateway.authentication.jwks_info.jwt_issuer
- gateway.authentication.jwks_info.jwt_audience
- gateway.session_control.clearing.enabled
- mcp_servers[0].authentication.type
forbidden_paths:
- gateway.session_control.client_id # Dtwo auth → platform-provisioned app
- gateway.intent.enabled
value_constraints:
- { path: gateway.session_control.clearing.enabled, equals: true }
semantic_rubric:
- "States clearing.enabled: true explicitly rather than relying on an empty block (which parks inert with intent off)"
clarifying_question_expected: false# session-control-intent-enabled.yaml
id: session-control-intent-enabled
tier: aspirational
tags: [session_control, clearing, intent]
user_prompt: |
Dtwo-authenticated gateway (acme.us.auth0.com, audience
https://api.acme.com) with intent capture turned on. Also arm
human-approved session clearing. One bearer-token MCP server at
https://mcp.acme.com/mcp.
followups:
- match: /audience/i
reply: "https://api.acme.com"
expect:
must_validate: true
required_paths:
- gateway.intent.enabled
- gateway.session_control
forbidden_paths:
- gateway.session_control.client_id
value_constraints:
- { path: gateway.intent.enabled, equals: true }
semantic_rubric:
- "Does not set clearing.enabled: false (rejected while intent capture is on)"
- "Prefers clearing.enabled: true over an empty block, per the updated guidance"
clarifying_question_expected: falseThe first one is the interesting case: it's exactly the "inert block" hazard d2#2036's advisory exists for, and How to run cd skill-harness
# Offline layer — deterministic, no LLM, ~1s. Fixture parsing + rubric + digest drift.
pnpm test
# (on pnpm 11 today, `pnpm install` trips on the package.json overrides — #36 fixes that;
# until then: node --import tsx --test 'src/__tests__/**/*.test.ts')
# Sanity-check fixture selection without any model calls
pnpm bench --dry-run --prompts='session-control-*'
# Live bench — actually asks Claude, scores each sample. Uses your local claude login, no API key.
pnpm bench --provider=claude-cli --temperature=0 --samples=5 \
--prompts='session-control-*' --output=./bench-results/session-control
Does CI run it? Not yet. The only checks on this repo today are GitHub's default CodeQL. Your #36 adds None of this gates the approval — happy for it to be a follow-up if you'd rather keep this PR to the docs. |
Every marker example in the skill takes a capability away: something
happened, so something is now blocked. A marker can equally give one —
the tool is off by default and the marker turns it on, so the agent earns
access by doing the right thing first instead of holding a standing
privilege. "You may not comment on a ticket you have not read" is the
same shape as "you may not delete a file you have not copied", and an
author following this skill today has no guidance for either.
The mechanism is identical; four things change, and the first is a
security property rather than a style preference.
Pin the writer's UID. The walk-all-writers read this skill teaches is
correct for a deny marker — more emitters only make the gate more
cautious. Invert the marker and it inverts too: any policy able to write
that key now mints the grant. The skill already says session state is not
access-isolated; this is the consequence, and it was never drawn.
Read fail-closed. "Marker Rego gotchas" tells authors to structure gates
so the marker's presence denies, which is the deny direction. A grant
gate is the mirror image, so absent state must resolve to "not granted".
That gotcha now cross-references the new section instead of reading as
universal advice.
Scope to the resource via the value. A writableKeySchema enumerates key
names under additionalProperties:false, so a per-resource key is
impossible — the id has to ride in the value and be compared against the
argument of the call being judged.
Take the id from the response. tool_post_invoke carries {name, text} and
no arguments, which is the point rather than a limitation: the id comes
from what the upstream actually returned, so a caller cannot mark a
resource it never fetched and a failed call grants nothing.
Also adds the three things that bite when accumulating a set: a policy
cannot know its own UID until it exists (create with a placeholder, then
update — the same two-step the shipped intent-capture policies use);
writes apply after the decision, so a read sees only previous requests;
and the substrate caps (1 KB per value, 16 keys per write, 64 per
session), none of which appeared anywhere in the skill and all of which
silently bound a design.
Closes with what a grant gate is not: parallel calls are both evaluated
against earlier state, so both can be allowed before either write lands.
Recording "already used" makes the record correct, not the gate
exclusive — single-use for sequential calls, stated plainly rather than
implying exactly-once.
Both worked examples were composed into complete modules and validated;
the pattern is running on a live gateway (Jira read grants commenting on
that issue, per-issue, while the same read blocks outbound Slack).
No digest regeneration: generate-schema-digest.mjs targets
dtwo-gateway-config/SKILL.md, not this skill.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-review of the previous commit found one wrong claim and two style breaks. Wrong: "a read sees the state left by previous requests, never the write this decision is about to make", and "a policy cannot count within a single request". The first half is right, the framing is not. The session-state ADR is explicit that a later evaluation in the SAME request does see writes committed by an earlier one — an earlier pipeline step, or ingress when the read happens on egress. That is exactly what makes marker gating work, so stating the opposite would have steered an author away from the ingress-marks / egress-reads pattern. The true restriction is narrower: a policy cannot read a value it wrote in the same evaluation. Style: the examples were fragments while every other example in this skill is a complete module (package, imports, default allow). An author pasting a fragment gets a parse error. Both examples are now complete and copy-pasteable, and both were run through validate-policy-rego. Also replaces a real staging policy UID with the REPLACE-WITH-… form the shipped intent-capture policies use. A live tenant identifier does not belong in a customer-facing skill, and the placeholder teaches the create-then-update two-step the surrounding text describes. While confirming the cap wording, checked VALUE_TOO_LARGE against _ON_DROP_HONORING_CODES: it is in the set, so the claim that onDrop deny_request turns an oversized value into a denied call is correct. Added the 16 KB per-session total alongside the other caps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
'Marker constraints today' teaches the walk-all-writers read with no mention that it is unsafe for a marker that grants a capability — the same gap just closed in dtwo-policy-rego's fail-open gotcha. An author reading only this skill would take any-writer as universal. One-line cross-reference to the new section. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skill showed multiple session_writes rules for the same key, never two different keys from one policy — so the most interesting marker pattern was the one it could not teach. Reading a confidential ticket both proves you read it (so you may comment on it) and puts sensitive content in the session (so you may not post it outbound): same fact, two gates, pointing opposite ways. Extends the accumulating writer with a second key conditional on response content, and shows the writableKeySchema carrying BOTH entries — a write to an undeclared key is dropped, so a schema listing only the first would silently lose the second, which is exactly the kind of failure that is hard to spot from the outside. Two notes on the consequences rather than just the mechanics: the grant half must be read with its writer pinned while the restriction half is safe to read any-writer, so one writer feeds two readers using two different idioms; and both keys count against the caps and carry independent TTLs, so a restriction that expires before its grant leaves the grant standing alone. Also fixes a defect this addition exposed in the preceding example: it bound _key directly from json.unmarshal(text).key and never bound the response object, so the new _labels rule referenced an undefined _issue. Rego fails silently, so an author following along would have got a marker that never fires and no error. The base example now binds _issue and derives _key from it, which is what the working policy does. Composed module re-validated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewed the whole branch against main, not only the last commit. The mechanics all check out against d2 and the fork (clear tool argument-less and matched by full federated name; the unarmed refusal returns before any mint so it emits no event; callback derived from the first jwt_audience; clearing.enabled:false while intent is on rejected at parse; deploy fails when clearing is armed with no ceremony client; registry entries omit transitions when unrestricted; CGNAT block tagged v1.0.7.0; the config digest regenerates byte-identical). What was left is prose. - The deny-reason bullet fused two separate points with a "because" that did not follow: you address the agent because only the tool call starts a clear; the per-gateway arming is a separate reason to keep the TTL in the sentence. Split them. - The preview snippet for pinned reads named the uid `_reviewer_uid`; the module it previews calls it `_writer_uid`. One name. - The accumulating writer's comment said "same two-step as above" — the two-step is explained below it, and the module above uses a placeholder for the OTHER policy's uid, not a two-step. - The fail-open gotcha referenced an `_active` helper no example defines (present on main; corrected while touching the line). - The two-marker extension repeats the grant rule without saying so, which reads as a second write to the same key. Labelled it as the same rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The vendored pair had drifted. Twelve commits landed upstream since the last pairing check, two touching the schema surface: OAuth `scopes` went from required to optional (schemaDefault `[]`, plus guidance for providers that reject any scope parameter), and contextforge moved to 1.0.7.1. That mattered for customers, not just for the pins: the vendored digest was telling readers `scopes` is required for `type: oauth`, so anyone following it would send a scope to a provider that rejects one. - Both files re-vendored from one revision; the bundle's sha matches the product repo's committed sidecar byte for byte. - Bundle version 3.0.0 -> 4.0.0, re-audited as the pin demands. 4.0.0 adds one rejection: a singular `scope` key on an oauth block whose `scopes` is absent or empty. Probed old against new: the shapes it newly rejects were ALREADY rejected under 3.0.0, for the different reason that `scopes` was then required — same verdict, different cause — and everything else is a relaxation. No fixture outcome moves; the fixtures that name `authentication.scopes` in required_paths all still set it. - All three pins bumped together. - The OAuth table gained a Default column: `scopes` now declares a default, and the coverage gate correctly refused to write a digest with nowhere to render it. - Fixed a hand-written line in the generator that still said both oauth shapes require `scopes`. Generated tables cannot contradict the artifact, but that prose could, and the coverage gate does not read prose — the same trap that left the product repo's README asserting the opposite of its own CI job. Verified: digest --check in sync, harness 177/177, no other skill claims `scopes` is required. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Re-paired — and the earlier baseline had drifted, so this supersedes it. Production is deploying, so I re-ran the pre-merge pairing check that comment asked for. It did not hold: twelve commits had landed upstream since, two touching the schema surface.
Both re-vendored from one revision; the bundle's sha matches the product repo's committed sidecar byte for byte, and all three pins moved together. The drift was customer-facing, not just bookkeeping. OAuth 4.0.0 re-audit, as the version pin demands. It adds one rejection: a singular Two side effects worth noting:
Digest |
What this changes
The marker guidance in the policy skills said a marker's TTL is the only way out of a block. It is not — a person can approve a clear in a browser — and the gap ran in the direction that hurts most: an author following it wrote deny reasons telling people to wait an hour for something they could resolve in under a minute. Clearing appeared nowhere in either policy skill, so this is mostly an addition rather than a correction sweep.
dtwo-gateway-configcovered thegateway.session_controlblock, but from a schema artifact old enough to documentclient_idas required and to know nothing ofredirect_uri— so it told every reader to register an IdP application and paste an id, which is the one thing a gateway on Dtwo authentication should not do. That skill's coverage is generated, so the artifact is re-vendored and the digest regenerated rather than hand-edited.dtwo-policy-regocurrent_intent(input)or its caller-supplieddescriptioninto areasonor a transform.data.dtwo.intent_registryis OPA base data in every policy bundle, with a worked read that sources a deny message from the registry instead of a pasted string, and adefaultfallback because an unregistered key would otherwise leave the wholereasonundefined.dtwo-gateway-policyintent_marker_incompatiblerecovery note.clearing: {enabled: true}explicitly, since an implicit block arms only while intent capture is on and parks inert otherwise — the one shape that leaves marker policies enforcing with no targeted way out of a marker.data.dtwo.intent_registryshape, flagged as not gated: the document ships to every gateway and always carriesmarkers[], whether or not intent capture is enabled.dtwo-gateway-config— regenerated, not hand-editedThe digest now carries, from the artifact:
client_id— requiredyes→no, with guidance to leave it unset under Dtwo authentication, where the platform provisions the ceremony application per organization and the deploy renders its id.redirect_uri— new field, with the derivation it overrides and the root-path constraint.clearing.enabled— writetruewhenever you want clearing, rather than leaving the block's meaning togateway.intent.enabled.client_id(the deploy fails rather than arming a ceremony with no application), andredirect_urireplaces the callback otherwise derived from the firstjwt_audienceentry.SESSION_CONTROL_REDIRECT_URIamong them.100.64.0.0/10note ongateway.ssrf.allowed_networks, and both newoauth_configfields.Vendored files and pins
Both vendored files come from one source revision. Refreshing the artifact alone would reintroduce the drift defect closed in #34 —
redirect_uridocumented but strict-rejected by an older bundle,SESSION_CONTROL_REDIRECT_URIreserved in the artifact but accepted insidegateway.advanced. All three pins move together: artifact sha, bundle sha, and bundle version2.0.0→3.0.0.That version bump is behavioural rather than structural. It adds two
must_validaterejections, both onoauth_config: an unimplementedtoken_endpoint_auth_method(RFC 7591'snoneincluded, since the gateway ignores it and the key used to be stripped silently), andclient_secret_basicwithout aclient_secret. No fixture or skill file exercises either key, so no rubric semantics move, and the.d.mtsshim is unchanged, so the harness's shape assumptions still hold.Delta against the previously vendored bundle — wider than the version bumps, which count only what the product repo chose to version.
session_control.redirect_uriand asession_controlblock with noclient_idare newly accepted; the old bundle rejected both, so the rubric would have scored the shape this skill recommends as invalid. OAuthscopesis now optional ([]default) — the digest previously told readers it was required, which would have sent a scope to providers that reject one. Six env-var names are newly rejected insidegateway.advanced(CPEX_CONTROL_TELEMETRY_DB_ENABLED,CPEX_CONTROL_TELEMETRY_ENABLED,CSRF_ENABLED,MCPGATEWAY_A2A_ENABLED,SESSION_CONTROL_REDIRECT_URI,SOTW_DELETE_POLICY), as is a singularscopekey on an oauth block with no scopes.token_endpoint_auth_method/omit_resourcenow survive a round-trip instead of being stripped. Probed against both bundles directly; every newly-rejected shape was already rejected before, for a different reason, so no rubric result moves.scripts/generate-schema-digest.mjsrenders the two newoauth_configfields. Its field order is an ordered allowlist, so a field added upstream is dropped rather than appended and the script's own coverage check refuses to write the digest until it is placed deliberately — that guardrail is now commented, because hitting it reads like a generator bug.Verification
opa check, and the registry example was evaluated withopa evalagainst a fixture registry and session state, including the missing-entry case thedefaultfallback exists for.generate-schema-digest.mjs --checkreports the digest in sync with the artifact.skill-harness:pnpm test— 177/177 passing against the new bundle (validator bundle 4.0.0).dtwo/.claude-plugin/plugin.jsonbumped to 1.1.8, since distributed skill content changed.