Skip to content

fix(mandate): fail closed on grant content the service cannot evaluate - #531

Merged
Levaj2000 merged 1 commit into
mainfrom
claude/mandate-strict-unknown-fields
Sep 11, 2026
Merged

fix(mandate): fail closed on grant content the service cannot evaluate#531
Levaj2000 merged 1 commit into
mainfrom
claude/mandate-strict-unknown-fields

Conversation

@Levaj2000

Copy link
Copy Markdown
Owner

A mandate is an authorization document, so an input this version does not understand may be one that restricts the grant. Both places that could happen resolved as "more authority", which is the wrong direction for an authorization decision to fail in.

Two failure modes, one principle

Unknown top-level fields. MandateDocument carried Pydantic's default extra="ignore", so an unrecognized field was silently dropped. The signature is computed over model_dump() (signing._build_signable_payload), so a mandate issued at a newer schema version lost its unknown fields before canonicalization and then failed with "one or more signatures are invalid" — indistinguishable from tampering, and the wrong thing to send an operator chasing.

Unevaluated conditions. This is the live one. conditions is inside the signed grant and nothing in this service evaluates it. An issuer could sign {"env": "staging"} and POST /api/v1/mandates/verify would answer:

{"valid": true, "checks": {"signatures_valid": true, "status_active": true,
 "not_expired": true, "within_spend_limit": true, "scope_sufficient": true}}

in production, ignoring a restriction its issuer signed. A caller could not tell that verdict apart from one over a mandate carrying no conditions at all.

The change

  • MandateDocument and IssueMandateRequest set extra="forbid". An unknown field is a parse error naming the field rather than a silent widening of authority. On the issue request this also means a misspelled spend_limmit is rejected instead of quietly producing a mandate with no spend limit.
  • /verify gains a sixth check, conditions_evaluable, which fails when conditions are present and unevaluated.
  • VerifyMandateRequest gains conditions_evaluated: bool = False. A caller holding its own evaluator, a gateway with request context this service does not have, asserts it did that work. It is an assertion of responsibility, not a bypass.

The principle is standards-independent, and the same one argued publicly in ocsf#1756: an unevaluable restriction cannot bound authority, so it must not read as satisfied. That argument was made outward before being adopted here; this closes the gap.

Blast radius, stated plainly

Breaking for external callers that submit mandates with populated conditions to /verify. They will now get valid: false with an error naming the condition keys and telling them to evaluate and resubmit.

Nothing in this repository is affected. The smoke script sends "conditions": {}, the overspend demo sends none, and no test used the field.

The gateway's live enforcement path is unaffected. It calls /api/v1/mandates/{id}/spend directly, not /verify. Worth naming as a separate gap rather than quietly widening this PR: conditions is unenforced on both paths. This change makes /verify honest about that. It does not make the gateway evaluate conditions, which is a real piece of work and a different change.

Tests

Seven new tests in mandate/tests/test_strict_unknown_fields.py cover both failure modes, the escape hatch, and that conditions stay inside the signature so an attacker cannot strip the field to buy a clean verdict.

Red-then-green verified: five of the seven fail against the previous code, with the sixth check reported as KeyError: 'conditions_evaluable'.

Check Result
New tests 7 passed
Full mandate suite 59 passed, 6 skipped
Gateway mandate tests 17 passed
ruff check / ruff format --check clean

The 6 skips are the ML-DSA cases that need liboqs, skipped on main as well.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XyU3PRkgvMxgimkPBC5726


Generated by Claude Code

A mandate is an authorization document, so an input this version does not
understand may be one that RESTRICTS the grant. Both places that could
happen resolved as "more authority", which is the wrong direction for an
authorization decision to fail in.

Unknown top-level fields. MandateDocument carried Pydantic's default
extra="ignore". The signature is computed over model_dump(), so a mandate
issued at a newer schema version lost its unknown fields before
canonicalization and failed as "one or more signatures are invalid",
indistinguishable from tampering. MandateDocument and IssueMandateRequest
now set extra="forbid": an unknown field is a parse error naming the field.

Unevaluated conditions. `conditions` is inside the signed grant and nothing
in this service evaluates it. An issuer could sign {"env": "staging"} and
/verify would answer valid: true in production, with no way for a caller to
tell that verdict from one over a mandate carrying no conditions at all.
/verify gains a sixth check, conditions_evaluable, which fails when
conditions are present and unevaluated. A caller holding its own evaluator
passes conditions_evaluated=true to assert it did that work; the flag is an
assertion, not a bypass.

The principle is standards-independent, and the same one argued publicly in
ocsf#1756: an unevaluable restriction cannot bound authority, so it must not
read as satisfied.

Breaking for external callers submitting populated conditions to /verify.
Nothing in this repository does: the smoke script sends an empty map, the
overspend demo sends none. The gateway's enforcement path is unaffected, it
calls /mandates/{id}/spend directly rather than /verify.

Seven tests cover both failure modes, the escape hatch, and that conditions
stay inside the signature so stripping them cannot buy a clean verdict.
Five of the seven fail against the previous code. Mandate suite 59 passed /
6 skipped, gateway mandate tests 17 passed, ruff check and format clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XyU3PRkgvMxgimkPBC5726
@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ai-identity-landing Ready Ready Preview Sep 11, 2026 2:32pm UTC
dashboard Ready Ready Preview Sep 11, 2026 2:32pm UTC

@Levaj2000
Levaj2000 marked this pull request as ready for review September 11, 2026 14:36
@Levaj2000
Levaj2000 merged commit 3c5d3c7 into main Sep 11, 2026
11 checks passed
@Levaj2000
Levaj2000 deleted the claude/mandate-strict-unknown-fields branch September 11, 2026 14:36
Levaj2000 added a commit that referenced this pull request Sep 11, 2026
…532)

PR #531 made /verify honest about conditions it could not evaluate. It did
not make anything evaluate them. The gateway, which actually gates requests,
never saw them at all: it draws against /mandates/{id}/spend and that
response carried no conditions. An issuer could sign {"env": "prod"} and a
staging agent would spend against the grant unimpeded.

Where the evaluation lives, and why it is not the gateway. The check has to
be atomic with the spend. Evaluating at the gateway means evaluating after
the draw returns, by which point an accepted spend is already recorded
against the budget of a request about to be denied. So the gateway sends the
context it alone holds and the service that owns the grant applies its own
terms before mutating state.

The context is agent metadata plus the endpoint and method. Those two are
reserved and win over a stored attribute of the same name: an agent whose
metadata claims endpoint=/v1/safe must not satisfy a condition pinning the
mandate there while calling something else.

Conditions reuse common.policy.eval.evaluate_when, the same function behind
a policy's `when` clause, so a mandate condition and a policy condition mean
the same thing and fail the same way. A missing key or an unknown operator is
a failed match, never a pass, so a caller that sends no context is denied on
a conditioned mandate. That is the intended rollout direction.

Ordering: after status, before currency and limit. A grant that does not
cover the request is not a budget question, and asking how much is left on an
inapplicable grant is the wrong question.

Evidence: deny_reason gains mandate_conditions_unmet, and the per-condition
results ride the audit row and the OCSF unmapped.mandate block as
conditions_checked / conditions_failed, so a denial says not just "denied"
but which field failed with expected against actual.

One judgment call, made explicit in the test that pins it. A condition-failing
settlement is refused rather than recorded, unlike an over-limit settlement.
Incrementing spent_cents asserts this grant funded the spend, which is what
the failed condition denies. Nothing is lost: the audit row and event log
capture the attempt either way, only the budget counter stays untouched.

Thirteen new tests. Mandate suite 68 passed / 6 skipped, gateway suite 142
passed, ruff check and format clean. Two pre-existing failures in this
environment (a rate-limiter timing test and seven OCSF export cases) were
confirmed identical on clean main and are unrelated.


Claude-Session: https://claude.ai/code/session_01XyU3PRkgvMxgimkPBC5726

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants