js-rigor: security affordances — gated provenance, audit hook, DoS hardening, explicit semantics
Per the trust-boundary direction (the caller owns evidence validation):
- Explicit possibilistic semantics module (src/core/possibility.js): the
single authoritative home for what each operator means (max = disjunctive
already-valid; min = unvalidified conjunctive ranking with the K-
validification and surfaced conflict mass; product = Thm-4 heuristic;
interior OWA = non-maxitive heuristic; reliability = adaptation, never
conflated with plausibility).
- Provenance is opt-in (re-entrant tracing practice): default check
results carry only {label, operator, regime}; conflictMass,
validifiedPossibility, sources, and nonMaxitive appear only under
includeMeta and on the explain surface. The direct-check cache now
caches only the meta-less form — includeMeta callers always get a fresh
full evaluation (previously a cached minimal result was served for
includeMeta requests, silently stripping detail).
- Audit affordance: new Arbiter({ audit }) emits one record per check
(decision, possibility, binary, partialGraphUsed, validityLabel,
sources). The engine stores nothing — the caller owns persistence;
zero cost when the hook is absent (and the full validity is forced only
on audit-enabled deployments).
- DoS hardening: partial-graph size limits are enforced BEFORE the
context allocation (the caller-supplied overlay is the per-check
allocation point); the CondensedGraphBinary reader gained full bounds
guards so malformed snapshot buffers fail with clean errors instead of
RangeError crashes or oversized allocations.
- New security-affordance pins: gating, audit records, and pre-allocation
limits.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { buildValidity, normalizeValidity, mergeValidity, DEFAULT_VALIDITY } from '../core/validity.js';
|
||||
import { buildValidity, normalizeValidity, mergeValidity, minimalValidity, DEFAULT_VALIDITY } from '../core/validity.js';
|
||||
import { Arbiter } from '../core/Arbiter.js';
|
||||
import { RuleEvaluator } from './RuleEvaluator.js';
|
||||
import { RuleCollector } from './RuleCollector.js';
|
||||
@@ -86,7 +86,7 @@ export class AuthorizationChecker {
|
||||
// Check cache first using composite key (if caching is enabled)
|
||||
let cachedResult = null;
|
||||
let cacheHint = null;
|
||||
if (!hasPartialGraph && this.decisionCache.directEnabled) {
|
||||
if (!hasPartialGraph && this.decisionCache.directEnabled && !includeMeta) {
|
||||
const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey);
|
||||
const [hitResult, status] = this.decisionCache.peekDirect(cacheKey);
|
||||
cachedResult = status === 'hit' || status === 'expired' ? { result: hitResult, timestamp: 0 } : null;
|
||||
@@ -111,8 +111,9 @@ export class AuthorizationChecker {
|
||||
reason: 'missing_node'
|
||||
};
|
||||
|
||||
// Cache the result (only when no partial graph — same guard as success path)
|
||||
if (!explain && !hasPartialGraph) {
|
||||
// Cache the result (only when no partial graph and the default
|
||||
// meta-less form — includeMeta callers get a fresh full evaluation)
|
||||
if (!explain && !hasPartialGraph && !includeMeta) {
|
||||
this._cacheDirectCheckResult(userKey, relation, objectKey, result);
|
||||
}
|
||||
return result;
|
||||
@@ -163,9 +164,11 @@ export class AuthorizationChecker {
|
||||
} else {
|
||||
result = {
|
||||
possibility: directRel.possibility,
|
||||
validity: directRel.validity !== undefined
|
||||
? buildValidity('identity', [effectiveRelation], [normalizeValidity(directRel.validity)], 1, directRel.possibility)
|
||||
: DEFAULT_VALIDITY,
|
||||
validity: includeMeta
|
||||
? buildValidity('identity', [effectiveRelation], [directRel.validity !== undefined ? normalizeValidity(directRel.validity) : 'heuristic'], 1, directRel.possibility)
|
||||
: minimalValidity(directRel.validity !== undefined
|
||||
? buildValidity('identity', [effectiveRelation], [normalizeValidity(directRel.validity)], 1, directRel.possibility)
|
||||
: DEFAULT_VALIDITY),
|
||||
// A denied decision (possibility 0) must not leak the
|
||||
// relation's reliability — the rule-collection path zeroes it.
|
||||
reliability: directRel.possibility > 0
|
||||
@@ -223,8 +226,8 @@ export class AuthorizationChecker {
|
||||
result.meta.cache = cacheHint;
|
||||
}
|
||||
|
||||
// Cache the result using composite key
|
||||
if (!explain && !hasPartialGraph) {
|
||||
// Cache the result using composite key (meta-less form only)
|
||||
if (!explain && !hasPartialGraph && !includeMeta) {
|
||||
this._cacheDirectCheckResult(userKey, relation, objectKey, result);
|
||||
}
|
||||
return result;
|
||||
@@ -348,7 +351,7 @@ export class AuthorizationChecker {
|
||||
const finalResult = {
|
||||
possibility: resPossibility || 0,
|
||||
reliability: res.reliability !== undefined ? res.reliability : 1.0,
|
||||
validity: res.validity || DEFAULT_VALIDITY,
|
||||
validity: includeMeta ? (res.validity || DEFAULT_VALIDITY) : minimalValidity(res.validity || DEFAULT_VALIDITY),
|
||||
...(collectValues && res.collectedValues && Array.isArray(res.collectedValues) && { collectedValues: res.collectedValues }),
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
@@ -566,7 +569,7 @@ export class AuthorizationChecker {
|
||||
const result = {
|
||||
possibility: maxAllow,
|
||||
reliability: maxAllow > 0 ? bestAllowReliability : maxDeny > 0 ? bestDenyReliability : 0,
|
||||
validity: finalValidity,
|
||||
validity: includeMeta ? finalValidity : minimalValidity(finalValidity),
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
allow: bestAllow,
|
||||
|
||||
@@ -144,9 +144,7 @@ export class CompiledEvaluator {
|
||||
const result = {
|
||||
possibility: relationStrength,
|
||||
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
|
||||
validity: directRel.validity !== undefined
|
||||
? buildValidity('identity', [relName], [normalizeValidity(directRel.validity)], 1, relationStrength)
|
||||
: DEFAULT_VALIDITY,
|
||||
validity: buildValidity('identity', [relName], [directRel.validity !== undefined ? normalizeValidity(directRel.validity) : 'heuristic'], 1, relationStrength),
|
||||
possibility_allow: relationStrength,
|
||||
possibility_deny: 0,
|
||||
...(includeMeta && {
|
||||
|
||||
@@ -71,9 +71,7 @@ export class DirectRule extends BaseRule {
|
||||
const authResult = {
|
||||
possibility: relationStrength,
|
||||
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
|
||||
validity: directRel.validity !== undefined
|
||||
? this._validity('identity', [relName], [this._relationValidity(directRel)], 1, relationStrength)
|
||||
: this._defaultValidity(),
|
||||
validity: this._validity('identity', [relName], [this._relationValidity(directRel)], 1, relationStrength),
|
||||
possibility_allow: relationStrength, // For binary mode
|
||||
possibility_deny: 0, // DirectRule doesn't deny
|
||||
...(includeMeta && {
|
||||
|
||||
@@ -24,6 +24,12 @@ export class Arbiter {
|
||||
|
||||
constructor(options = {}) {
|
||||
this.options = options;
|
||||
|
||||
// Audit affordance (caller-wired, zero cost when absent): invoked once
|
||||
// per check with a minimal decision record. The engine does NOT store
|
||||
// audit state — the caller owns persistence and retention. The record
|
||||
// is built only when the hook exists, so the default path is untouched.
|
||||
this._auditHook = typeof options.audit === 'function' ? options.audit : null;
|
||||
|
||||
// Injectable cache factory (DI): defaults to the built-in SimpleLRUCache.
|
||||
// Pass options.cacheFactory to substitute another cache implementation.
|
||||
|
||||
@@ -92,6 +92,10 @@ export class ExplainSerializer {
|
||||
result: allowPossibility > 0,
|
||||
possibility: allowPossibility,
|
||||
...(reliability !== undefined ? { reliability } : {}),
|
||||
// The validity block is the full debugging detail on the explain
|
||||
// (internal) surface — the public check result carries only the
|
||||
// minimal label unless includeMeta is set.
|
||||
...(result?.validity ? { validity: result.validity } : {}),
|
||||
determinedBy: ruleMeta?.reason || result?.reason || null,
|
||||
level,
|
||||
ruleType: ruleMeta?.ruleType || ruleMeta?.type || null,
|
||||
|
||||
@@ -13,6 +13,20 @@ export class ArbiterChecks {
|
||||
|
||||
// Extract epsilon/delta if present and pass through
|
||||
if (options.partialGraph && !options.partialGraphContext) {
|
||||
// Reject oversized overlays BEFORE allocating the context: the
|
||||
// caller-supplied partial graph is untrusted input, and the check is
|
||||
// the allocation point for every decision (DoS surface).
|
||||
const policy = this.arbiter.partialGraphPolicy || {};
|
||||
const rels = Array.isArray(options.partialGraph.relations) ? options.partialGraph.relations : [];
|
||||
const nodes = Array.isArray(options.partialGraph.nodes) ? options.partialGraph.nodes : [];
|
||||
const maxRelations = policy.maxRelations !== undefined ? policy.maxRelations : 2000;
|
||||
const maxNodes = policy.maxNodes !== undefined ? policy.maxNodes : 1000;
|
||||
if (rels.length > maxRelations) {
|
||||
throw new Error(`Partial graph exceeds max relations: ${rels.length} > ${maxRelations}`);
|
||||
}
|
||||
if (nodes.length > maxNodes) {
|
||||
throw new Error(`Partial graph exceeds max nodes: ${nodes.length} > ${maxNodes}`);
|
||||
}
|
||||
options.partialGraphContext = this.arbiter._createPartialGraphContext(options.partialGraph);
|
||||
}
|
||||
|
||||
@@ -53,6 +67,12 @@ export class ArbiterChecks {
|
||||
clientStateId,
|
||||
...options
|
||||
};
|
||||
// Audit needs the full validity detail (sources, conflict mass) even
|
||||
// when the caller did not ask for includeMeta; the hook is opt-in, so
|
||||
// this only ever costs on audit-enabled deployments.
|
||||
if (this.arbiter._auditHook && authOptions.includeMeta === undefined) {
|
||||
authOptions.includeMeta = true;
|
||||
}
|
||||
// Ensure epsilon/delta are present if specified
|
||||
if (epsilon !== undefined) authOptions.epsilon = epsilon;
|
||||
if (delta !== undefined) authOptions.delta = delta;
|
||||
@@ -71,6 +91,22 @@ export class ArbiterChecks {
|
||||
}
|
||||
|
||||
if (options.explain) return result;
|
||||
|
||||
if (this.arbiter._auditHook) {
|
||||
this.arbiter._auditHook({
|
||||
timestamp: Date.now(),
|
||||
userKey,
|
||||
relation,
|
||||
objectKey,
|
||||
decision: result.possibility > 0 ? 'allow' : 'deny',
|
||||
possibility: result.possibility,
|
||||
binary: !!options.binary,
|
||||
partialGraphUsed: !!options.partialGraphContext,
|
||||
validityLabel: result.validity ? result.validity.label : null,
|
||||
sources: (result.validity && result.validity.sources) || []
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,20 @@ class BinaryReader {
|
||||
this.decoder = new TextDecoder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds guard: the buffer is caller-supplied (snapshot restore input),
|
||||
* so every read must be validated BEFORE the DataView/Uint8Array throws
|
||||
* or over-allocates. A clean error here prevents RangeError crashes and
|
||||
* oversized allocations from malformed buffers.
|
||||
*/
|
||||
ensure(bytes) {
|
||||
if (bytes < 0 || this.offset + bytes > this.view.byteLength) {
|
||||
throw new Error(
|
||||
`CondensedGraphBinary: read past end of buffer (offset ${this.offset}, need ${bytes}, length ${this.view.byteLength})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
align(alignment) {
|
||||
if (alignment <= 1) return;
|
||||
const padding = (alignment - (this.offset % alignment)) % alignment;
|
||||
@@ -95,36 +109,42 @@ class BinaryReader {
|
||||
}
|
||||
|
||||
readUint8() {
|
||||
this.ensure(1);
|
||||
const value = this.view.getUint8(this.offset);
|
||||
this.offset += 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
readUint16() {
|
||||
this.ensure(2);
|
||||
const value = this.view.getUint16(this.offset, true);
|
||||
this.offset += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
readUint32() {
|
||||
this.ensure(4);
|
||||
const value = this.view.getUint32(this.offset, true);
|
||||
this.offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
readInt32() {
|
||||
this.ensure(4);
|
||||
const value = this.view.getInt32(this.offset, true);
|
||||
this.offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
readFloat64() {
|
||||
this.ensure(8);
|
||||
const value = this.view.getFloat64(this.offset, true);
|
||||
this.offset += 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
readBytes(length) {
|
||||
this.ensure(length);
|
||||
const bytes = new Uint8Array(this.view.buffer, this.offset, length);
|
||||
this.offset += length;
|
||||
return bytes;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* src/core/possibility.js — explicit possibilistic semantics.
|
||||
*
|
||||
* The single authoritative home for what the engine's operators MEAN in
|
||||
* possibility theory, in the spirit of Martin's plausibility contours and
|
||||
* Cella's FVN fusion (see zig-contour/spec/02-fusion.md).
|
||||
*
|
||||
* Possibility theory axioms the engine honors:
|
||||
* - Possibility is maxitive: plausibility of a disjunction is the MAX of
|
||||
* the plausibilities. This is why OR (union) is max and why max is the
|
||||
* only operator that is already "valid and normalized" under arbitrary
|
||||
* dependence (Cella §7.2).
|
||||
* - A possibility value is a ranking, not a probability: 0.6 means "this
|
||||
* is the least plausible option whose negation is not strictly more
|
||||
* plausible" — it does NOT mean a 60% chance.
|
||||
* - Conjunctive combination (AND) is the MIN, and the raw min is an
|
||||
* UNVALIDIFIED ranking: the arbitrary-regime validification is
|
||||
* min(1, K*gamma_min) (Prop 1 bound), and the pre-normalization
|
||||
* suppression (conflict mass = 1 - gamma) must be surfaced, never
|
||||
* silently dropped.
|
||||
* - The PRODUCT operator (P(A)*(1-P(B)) in exclusion/defeasible) has no
|
||||
* linear validification under arbitrary dependence (Cella Thm 4). Its
|
||||
* outputs are ranking heuristics, never valid possibilities.
|
||||
* - Interior OWA weights produce convex mixtures of plausibilities,
|
||||
* which are not maxitive; they are scoring heuristics (nonMaxitive).
|
||||
* - Reliability is NOT a possibility: it is a scalar confidence that
|
||||
* adapts the fusion ranking (a fusion-step combiner), and must never
|
||||
* be presented as, or conflated with, the plausibility value.
|
||||
*/
|
||||
|
||||
export const OPERATOR_SEMANTICS = {
|
||||
identity: {
|
||||
class: 'disjunctive',
|
||||
label: 'a single source; the value is the source\'s plausibility',
|
||||
validity: 'preserves the source label',
|
||||
validified: false,
|
||||
nonMaxitive: false
|
||||
},
|
||||
max: {
|
||||
class: 'disjunctive',
|
||||
label: 'OR: plausibility of the union of options',
|
||||
validity: 'already valid and normalized under arbitrary dependence (Cella §7.2); fused label = weakest source label',
|
||||
validified: false,
|
||||
nonMaxitive: false
|
||||
},
|
||||
min: {
|
||||
class: 'conjunctive',
|
||||
label: 'AND: plausibility of the conjunction of options (unvalidified ranking)',
|
||||
validity: 'approximate at best; arbitrary-regime validification min(1, K*gamma_min) (Prop 1); conflict mass surfaced',
|
||||
validified: true,
|
||||
nonMaxitive: false
|
||||
},
|
||||
product: {
|
||||
class: 'heuristic',
|
||||
label: 'exclusion/defeasible: P(A)*(1-P(B)) style combination',
|
||||
validity: 'always heuristic — the product operator has no linear validification under arbitrary dependence (Cella Thm 4)',
|
||||
validified: false,
|
||||
nonMaxitive: false
|
||||
},
|
||||
owa: {
|
||||
class: 'heuristic',
|
||||
label: 'ordered weighted average with interior weights: convex mixture of sorted plausibilities',
|
||||
validity: 'always heuristic — convex mixtures are not maxitive',
|
||||
validified: false,
|
||||
nonMaxitive: true
|
||||
},
|
||||
mixed: {
|
||||
class: 'heuristic',
|
||||
label: 'multiple contributing rules with different operators',
|
||||
validity: 'heuristic if any contributor is heuristic or non-maxitive',
|
||||
validified: false,
|
||||
nonMaxitive: false
|
||||
}
|
||||
};
|
||||
|
||||
export function operatorSemantics(operator) {
|
||||
return OPERATOR_SEMANTICS[operator] || OPERATOR_SEMANTICS.mixed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The decision semantics every consumer MUST respect:
|
||||
* - possibility is a plausibility ranking; thresholds are ranking
|
||||
* cutoffs, not probability bounds
|
||||
* - a heuristic-labeled result carries no validity guarantee
|
||||
* - reliability adapts the ranking; it does not measure plausibility
|
||||
* - conflictMass is a debugging diagnostic, not a decision input
|
||||
*/
|
||||
export const DECISION_SEMANTICS = Object.freeze({
|
||||
possibilityIsRanking: true,
|
||||
reliabilityIsAdaptation: true,
|
||||
conflictMassIsDiagnostic: true,
|
||||
heuristicResultsCarryNoGuarantee: true
|
||||
});
|
||||
@@ -143,6 +143,21 @@ export function buildValidity(optsOrOp, sourcesArg = [], sourceLabelsArg = [], K
|
||||
}
|
||||
|
||||
/** Merge several rule-level validity blocks into one (weakest label, unioned sources). */
|
||||
/**
|
||||
* Minimal public form: label + operator only. Conflict mass, the
|
||||
* validified value, sources, and the non-maxitive flag are debugging /
|
||||
* internal-logging detail (re-entrant tracing practice: verbose traces are
|
||||
* opt-in, never on the default result path).
|
||||
*/
|
||||
export function minimalValidity(block) {
|
||||
if (!block) return { label: 'heuristic', operator: 'identity', regime: 'arbitrary' };
|
||||
return {
|
||||
label: block.label || 'heuristic',
|
||||
operator: block.operator || 'identity',
|
||||
regime: block.regime || 'arbitrary'
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeValidity(blocks) {
|
||||
const present = (blocks || []).filter(Boolean);
|
||||
if (present.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user