Repository navigation
ceilings: strict JSON types in constraint evaluation; parity with the Python implementation - #2
Merged
Merged
Conversation
…JSON scalar
A Set compares an array or an object by identity, so no request value
ever equals a one_of / not_one_of member. An allow-list refused such a value
as a non-member; a deny-list waved it through, so `not_one_of: ["rm"]`
permitted `tool: ["rm"]`: fail-open in every release from 0.1.0 through
0.13.0. Both lists now refuse a value that is not a string, a number, a
boolean or null, as ceiling_exceeded, with limit = the members in wire
order, requested = the value, and the message
"<an array|an object|a value that is not JSON> cannot be compared with
<one_of|not_one_of> members; refused", the Python implementation's words
for attenu-ops#110. null and an absent field still assert nothing, and a
scalar is compared as before, with no message. The rule is in
Allow.permits and Deny.permits, so it holds in Guard.check, in
VerifiedChain.permits after load(), and in verifyBundle's containment check.
Allow and Deny now read only the context's own fields: ctx["constructor"]
was Object's constructor on any plain object, so Allow("constructor", ...)
refused a context that did not hold the field, where Python reads it as
absent.
test/typed-members.test.ts has ten tests. Five passed unchanged on 0.13.0:
four pin typed membership against the Python implementation's answers (true
is not 1, the re-emitted order, -0 as 0) and one pins that null and the
scalars are compared as before. The other five failed there.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
…ints compare as JSON A one_of / not_one_of that is not an array was read anyway: null became the empty list and a string its characters, so `not_one_of: null` was an empty deny-list and a token or a bundle carrying it verified. An object, a number or a boolean threw a TypeError in its own words. Allow and Deny now throw a TypeError naming the list and the key, "<list> of constraint <key repr> is <an object|null|a string|a number|a boolean>, not an array", the Python implementation's text (it raises ValueError), so a token carrying one is refused as malformed and a bundle reports unreadable_authority / unreadable_granted with that text. An absent one_of is still the empty list. Measured on 8 packages from 0.1.0 to 0.13.0. UnknownCeiling.subsumes compared JSON text with only the top-level keys sorted, so two constraints equal as JSON differed when a nested object listed its keys in another order, and a bundle whose child repeated its parent's unknown constraint that way failed monotonicity. It now compares canonicalJson (RFC 8785) bytes, as the Python implementation does: true is not 1, 1.0 is 1, key order is no difference at any depth, and a value the canonicalizer refuses is identical to nothing. canonicalPairs is gone. RED on 6146711: 5 of the 6 new tests in test/typed-members.test.ts; the sixth pins that an array, a Set and an absent one_of still work. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
…thon implementation's words
describe() printed a ceiling without its own describe as JSON text, and an
unknown constraint's denial read `key="quota"`, with a missing key recorded
as "" and a numeric key as a string in the reason's constraint. The deny
entry a Guard writes for the same call was therefore different bytes from
the two implementations. describe() now prints the key through pyStr and the
wire form through pyRepr, `quota={'key': 'quota', 'type': 'x-unknown',
'max': 1}`, and the denial reads `unrecognised constraint type for
key='quota'; fail-closed` with constraint = the key as the wire carried it
(null, a number or a string). describeInFinding reuses describe() for these.
Every release from 0.1.0 through 0.13.0 had the old text (8 packages run).
test/ceiling-finding.test.ts pinned the old describe() text and now pins
Python's. CHANGELOG records the integral-float print difference (100.0 vs
100) as a known difference.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
… JSON type `<=` let "50", [50] and true pass a row cap and every other numeric cap, and String() let ["/tmp/x"] pass the prefix "/tmp/" as the text "/tmp/x" and true pass the prefix "t" as "true". Every release from 0.1.0 through 0.13.0 (8 packages run; the five permits methods are identical at all 16 tags). Each built-in ceiling now refuses a request value of the wrong type as ceiling_exceeded and never coerces it: a numeric cap takes a number, a prefix and an egress rank take a string, an allow-list or a deny-list takes a string, a number or a boolean. One message per ceiling kind, the Python implementation's words: "<kind> cannot be compared with a maximum; refused" "<kind> cannot be compared with a prefix; refused" "<kind> cannot be compared with an egress rank; refused" "<kind> cannot be compared with <one_of|not_one_of> members; refused" where <kind> is a string, a number, a boolean, an array, an object, or a value that is not JSON. null and an absent field still assert nothing. Every ceiling reads only the context's own fields (ownValue), so a prefix on a field named constructor no longer refuses a context without one. jsonKind / wrongKind / refusal replace notAScalar / outside. The langgraph adapter builds no quantity; it passes the caller's context through. RED on 795d6fb: 3 of the 3 new tests in test/typed-members.test.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
A max that is not a number (a boolean, a string, null, an array, an object, or no max at all), a prefix that is not a string, an egress rank other than none, internal or any, and a field or applies_to that is not a string (null still leaves them unset) are now refused when the ceiling is built, with a TypeError "<member> of constraint <key repr> is <kind>, not <expected>", the Python implementation's text (it raises ValueError): max of constraint 'max_rows' is a boolean, not a number prefix of constraint 'path' is a number, not a string rank of constraint 'egress' is 'everywhere', not 'none', 'internal' or 'any' field of constraint 'region' is a number, not a string applies_to of constraint 'max_calls' is a boolean, not a string An absent max, prefix or rank arrives as undefined and reads "is absent". A token carrying one is refused as malformed and a bundle reports unreadable_authority / unreadable_granted with that text. This is a wire-behaviour change: tokens that carried such a bound and verified before are refused. Every release from 0.1.0 through 0.13.0 accepted them (4 packages run): max: true read as 1, max: "5" was coerced, an unknown rank ranked above "any" and so admitted every request (fail-open), prefix: 5 matched as the text "5", and field: 5 read ctx["5"]. The draft defines max as a number and prefix as a string. RED on 8e2a8ec: the bounds test in test/typed-members.test.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
Authority.permits set the reserved _scope only when the context did not carry one, so a context's own _scope decided which scoped CallLimit applied: after new CallLimit(1, "crm.read") had refused a second call, a third carrying _scope: "other.x" was allowed, and a call could be charged to another scope's limit instead. Every release from 0.1.0 through 0.13.0 (4 packages run). permits now always sets _scope to the scope it is checking and ignores the caller's, which covers Guard.check, VerifiedChain.permits after load() and verifyBundle's containment check. Same change as the Python implementation. A calls count supplied in the context still wins over the guard's own count: the declared-quantity limit the Python implementation's docs/RED-TEAM.md describes, left alone here. RED on 8e2a8ec: the _scope test in test/typed-members.test.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
Three more constraint members are malformed when they do not have the shape the draft or the scope grammar gives them, in the Python implementation's words (review of #2): - key: every constraint, an unknown type included, needs a string key. "key of a constraint is <absent|null|a number|a boolean|an array|an object>, not a string". A number, null or a boolean key was read as String(key), so 5 and "5" were one dimension, and an absent key loaded and read the field "undefined". - one_of / not_one_of absent: "one_of of constraint 'region' is absent, not an array". An absent deny-list read as an empty one and bounded nothing. - applies_to: a string that is not a scope by the scope grammar (an exact scope, or a terminal .* wildcard) is "applies_to of constraint 'max_calls' is '*', not a scope". "*", "crm" or "CRM.READ" applied to no call, so the limit bounded nothing. The scope grammar moves to ceilings.ts (SCOPE_RE, not exported from the package), which authority.ts imports. Allow, Deny and Prefix check their key in-process too. A token's malformed-constraint message no longer puts "TypeError: " before the error's text, so it is Python's byte for byte. The message-parity cases follow the Python implementation: a null key is unreadable, and the dimension-rewrite case carries its control characters in the key, since applies_to can no longer hold them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
Ceilings pair by key, and an allow-list and a deny-list (or any two
ceiling types) under one key are not comparable. isNarrowerThan called the
parent's subsumes() with the child's ceiling regardless, which threw
TypeError ("undefined is not iterable") out of verifyBundle and out of
load(). Now, as in the Python implementation (review of #2):
- every built-in subsumes() is false for a ceiling of another class;
- Authority.isNarrowerThan and the verifier's monotonicity detail check
the class first (sameType, Python's `type(a) is type(b)`), so a custom
ceiling's subsumes() is never handed one;
- Authority.meet refuses to combine them: TypeError "constraint 'region'
has a different ceiling type on each side; neither narrows the other",
where Python raises ValueError with the same text.
So load() reports not_narrower, and a bundle reports a monotonicity finding.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
…lared Two holes in metering (review of #2): - Strict metering asked `field in context`, which is true for a field named like an Object.prototype member on every plain object: a metered ceiling reading `constructor` passed with `{}`, a regression from this PR's own-property reads. It reads the context's own value now. - A null quantity asserts nothing, and every ceiling reads it as absent. Yet strict metering counted `{rows: null}` as declared, and the call meter treated `{calls: null}` as an explicit count that wins over its own, so `{rows: null, calls: null}` passed a metered CallLimit(1) any number of times. Strict metering now refuses a null quantity as unmetered, and the guard fills and counts a null call count from its own meter. An explicit numeric count still wins, as RED-TEAM.md documents. The same in the Python implementation. Two own-property tests that passed for the wrong reason now discriminate: the deny half of the inherited-name test reads an inherited JSON value, and the Prefix case uses a prefix that "function toString() ..." does not start with. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
… a number Review of #2: - Wire order: members are sorted by their printed text, then by JSON type (null, boolean, number, string). Ties ("1" and 1, "True" and true, "None" and null, "1.5" and 1.5) used to keep their arrival order, so equal member sets re-emitted different bytes. The order is total now, the same in the Python implementation. - Findings print a string member through Python's repr: `t in [1]` against `t in ['1']`, where both printed `t in [1]`, and `['eu\nOK', 'us']` where a member needing escapes printed as JSON. describe() is unchanged. - A RawNumber, which this library's parseJson returns for every number, counts as a number in every ceiling's type check: a context, a bound or a member read with parseJson compares as the number it holds. A context from parseJson was refused by every numeric cap ("an object cannot be compared with a maximum"), and a deny-list built from parseJson members refused nothing, since a Set compares objects by identity. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
pyRepr printed a plain number with String(), so a fraction read differently from the Python implementation's repr: 0.00001 against 1e-05, -2.5e-7 against -2.5e-07. A ceiling built from the wire holds plain numbers, so an unknown constraint's describe() and an authority message that quotes a constraint differed (review of #2). A non-integral number now prints through pyFloat, Python's repr of a float. An integral number still prints as an integer: without its literal this build cannot tell 100 from 100.0, the known difference the CHANGELOG records. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
Under Changed: a key, an absent one_of and an applies_to are read strictly; findings quote string members; a malformed constraint's token message carries the error's text alone; Ceiling.permits called directly reads only own fields (an inherited `rows` was refused by 0.13.0 and is permitted now; every higher-level path already read own fields); bundles recorded by 0.1.0 through 0.13.0 with coerced contexts or a `_scope` override can fail re-verification, as can Python-recorded ones. Under Fixed: a parseJson number passed a deny-list of numbers; different ceiling types under one key threw; a null quantity passed a metered call; tied members kept their arrival order; a fraction printed in JavaScript's form. Each measured on the 0.13.0 package, with the code checked at every tag from 0.1.1. Corrected: null leaves applies_to unset only in-process; an absent one_of is no longer an empty list; RFC 8785 comparison of unknown constraints differs from Python for an integral float past 2^53; the unknown constraint's describe() matches Python except where Known differences says. Known differences added for what stays different: -0 bounds, list or object members, floats past 2^53 in unknown constraints, integer-like member names, and each runtime's Unicode tables. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
Two more shapes are malformed, in the Python implementation's words:
- A constraint that is not a JSON object: "a constraint is a string, not
an object" (or a number, a boolean, null, an array). Every such value
but null loaded as an unknown constraint, and null threw "Cannot read
properties of null (reading 'type')".
- A type that is present and not a string: "type of constraint
'max_rows' is null, not a string" (or a number, a boolean, an array, an
object). A null type was read as absent and the constraint routed by its
key, so {"key": "allow", "type": null, "one_of": ["us"]} loaded as an
allow-list where the Python implementation loaded an unknown
constraint; a number, a list or an object loaded as an unknown one.
The key is read first, so a constraint with neither reports the key.
Measured on the 0.13.0 package; ceilingFromWire is the same at every tag
from 0.1.1. A token carrying either is refused as malformed, and a bundle
reports the authority unreadable. CHANGELOG under Changed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
- A constraint member named like an Object.prototype member is one this
build does not read (N1): the whole-read check asked `k in emitted`,
true for `constructor` on every plain object. It reads the emitted
constraint's own members now, so such a token is refused in the Python
implementation's words.
- Mixed types in a delegation (N2a). Two different ceiling types under
one key throw AuthorityError out of meet (reason not_narrower, detail
{constraint: key}), so Guard.delegate records spawn_denied; 0.13.0
threw TypeError for some pairs, granted the parent's ceiling for
others, and failed the invariant check for a requested unknown. The one
exception is a parent's constraint this build does not define: the
child inherits it, as 0.13.0 did, since it denies every action. A
request carrying one under a parent's other ceiling is refused like any
other pair. isNarrowerThan and the monotonicity detail are unchanged.
- Findings print string members in their escaped JSON form under the
display rule: ASCII, the Python implementation's text on every Python
version, with "1" still told from 1.
- The wire order of a member set is sorted once (WeakMap); each caller
gets its own copy.
- Two constraints under one key in one authority are malformed: "two
constraints share the key 'region'; an authority holds one per key".
- A custom ceiling's bare Decision.deny([]) denies, with
ceiling_exceeded and "denied without a reason".
- Tests pin isNarrowerThan's and the monotonicity detail's class checks
with a custom ceiling (N2b, N3), each built-in's subsumes against a
lookalike, and the parsed-number bounds of SpendCap and CallLimit.
CHANGELOG: each of these, measured on the 0.13.0 package, with the
delegation behaviour in both directions; a boolean type loaded as
unknown too; "REQUIRED" for key; exponent literals and the Unicode tables
under Known differences.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
Round-3 review: - N-A. The wire order of a member set is computed once, keyed by the set, but oneOf and notOneOf held a plain Set that a caller could still add to through a cast. After the first toWire(), permits() then decided by members the wire did not list, so a token or a ledger carried a narrower deny-list than the process enforced, and another verifier could admit what this one refused. Each is now a read-only set (Members) whose add, delete and clear throw a TypeError, with the members in a private field no Set method can reach, as the Python implementation's member set has no mutators. The review's repro is a test. - N-B. The CHANGELOG states what 0.13.0 did for a requested unknown constraint by the parent's ceiling: allow, deny and prefix parents threw TypeError with nothing on the ledger, an egress-rank parent delegated with its own rank and dropped the request, and a numeric-cap parent recorded spawn_denied with the reason integrity. - N-C. meet's comment states its one order-dependent case: a parent's unknown constraint passes down; a requested one under a parent's other ceiling is refused. No behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
The entry for a custom ceiling's denial without a reason now names the readers it changes: Guard.check, VerifiedChain.permits and verifyBundle's containment check, each measured on the 0.13.0 package, and says that an archived bundle that recorded allows under such a ceiling, and verified before with the ceiling registered, now fails containment. Text only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKKB5EXP8dbKmv7P7M56Rw
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Constraint evaluation is now strict about JSON types, and reads every constraint whole. This matches the Python implementation (attenu-io/attenu-guard#30).
Fixed. Each item was present from 0.1.0; the CHANGELOG states each one.
_scopecould move a call off its meter.nullquantity counted as declared under strict metering.not_narrowerand recorded asspawn_denied. A parent's unknown constraint still passes to the child.Changed. Each refused with one message on every path.
key,typeorapplies_toof the wrong type; a constraint that is not an object; aone_of/not_one_ofthat is absent or not an array; amaxthat is not a number; aprefixthat is not a string; an egressrankoutside none/internal/any. Tokens that verified with such constraints are now refused.Ceiling.permitscalled directly._scope-diverted calls, and meet-widened delegations.Parity with the Python branch, both heads: 2,814/2,814 permit decisions; 1,400/1,400 bundle exit codes; 99/99 ledger entries byte-identical; 1,682/1,682 lattice pairs. Five known differences are listed in the CHANGELOG. Reviewed in three independent rounds.
🤖 Generated with Claude Code