@atlasauth/pca-analyzer 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,90 @@
1
- # Temporary Holding Version
1
+ # @atlasauth/pca-analyzer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Static analyzability of authority.** Proof-Carrying Authority can prove a quantitative trust-budget
4
+ bound and verify one action, but on its own it cannot statically answer *"what can this whole delegated
5
+ authority ever do?"* This package decides that over PCA's predicate/caveat policy — the conceptual gap
6
+ the roadmap flagged — without a heavyweight SMT dependency.
7
+
8
+ It is a sound, **bounded** decision procedure in the spirit of AWS Cedar's symbolic compiler (SymCC,
9
+ arXiv:2403.04651), which decides policy equivalence / subsumption / always-allow-deny with
10
+ counterexamples. Instead of discharging to Z3, this analyzer exploits a *finite-distinguishing-value*
11
+ property of PCA's predicate theory and decides by enumeration against the **real** `evaluatePredicates`
12
+ from `@atlasauth/pca`.
13
+
14
+ ## Queries
15
+
16
+ All take a `CompiledPolicy` (from `compilePolicy` / `agent().policy`) or a bare
17
+ `{ predicates, caveats? }`. Every negative/relational verdict carries a concrete counterexample or
18
+ witness.
19
+
20
+ | Function | Decides |
21
+ |---|---|
22
+ | `reachable(policy, action)` | Can any input complete `(verb, resource, params)` into an ALLOW? Returns a verified `witness`. |
23
+ | `alwaysDenies(policy, verb?)` | Is the policy vacuous (denies everything / everything for `verb`)? |
24
+ | `alwaysAllows(policy, verb)` | Is the policy total for `verb` (admits every action of it)? |
25
+ | `subsumes(a, b)` | Does `a` permit everything `b` does (`admitted(b) ⊆ admitted(a)`)? The delegation-safety check: a child must be subsumed by its parent. |
26
+ | `disjoint(a, b)` | Do the two admit no common action? |
27
+ | `equivalent(a, b)` | Do the two admit exactly the same actions? |
28
+ | `intentConformance(policy, intent)` | Does the policy admit **only** actions inside a declared intent envelope (verbs + resource scopes + amount ceiling)? |
29
+
30
+ `intentConformance` is the safety check distinct from the runtime trust-budget. It catches the
31
+ *"a $14k renewal under a $10k cap on an approved vendor still violates intent"* class: an action that
32
+ passes the policy's predicates and stays under budget, yet falls outside what the principal intended,
33
+ because the policy's own ceiling is higher than (or absent relative to) the intent cap.
34
+
35
+ ```ts
36
+ import { subsumes, intentConformance } from '@atlasauth/pca-analyzer';
37
+
38
+ // Delegation safety: a child capability must not widen its parent.
39
+ const ok = subsumes(parentPolicy, childPolicy).subsumes; // false => counterexample attached
40
+
41
+ // Intent conformance: does the authority stay inside what was meant?
42
+ const r = intentConformance(policy, { verbs: ['stripe.refund'], maxAmount: 10000 });
43
+ // r.conforms === false, r.violations[].kinds includes 'over-amount' for the $14k action
44
+ ```
45
+
46
+ ## How it decides (the engine)
47
+
48
+ PCA's `where` conditions are a conjunction of threshold tests (`lt/lte/gt/gte/eq` on numbers),
49
+ membership tests (`eq/ne/in/nin` over finite values) and string `prefix` tests; predicates match on a
50
+ verb allowlist and a resource matcher; the envelope caveats are append-only and conjunctive. The
51
+ admitted region of a policy is therefore a **union of cells** of the arrangement defined by the literals
52
+ that appear in it. The analyzer builds one representative value per cell — every literal and numeric
53
+ threshold, the midpoints between consecutive thresholds, boundary points just outside the range, prefix
54
+ probes, a *fresh* verb / resource / value matching none of the literals, and an *absent* probe for each
55
+ field — then evaluates both policies at every representative with the real evaluator. Because that set
56
+ is distinguishing, agreeing on all representatives implies agreeing on all actions, so the relational
57
+ queries are decided exactly, and any mismatch is a real counterexample. Caveat satisfiability is decided
58
+ separately (`analyzeCaveats`): the context (time window, blast radius, depth, rate) is taken as
59
+ favourably chosen, and the one action-tied caveat, `reversibility_max`, becomes a per-action cap.
60
+
61
+ ## Decided exactly vs. sound-over-approximated
62
+
63
+ **Decided exactly** (the fragment):
64
+
65
+ - verb allowlists (`string`, `string[]`, `'*'`);
66
+ - resource matchers that are exact, `'*'`, or a trailing-`*` **prefix**;
67
+ - numeric `where` over `lt/lte/gt/gte/eq` (the interval engine; merges bounds, detects empty
68
+ conjunctions such as `amount >= 100 AND amount <= 10`);
69
+ - finite-domain `eq/ne/in/nin` and string `prefix` (the set engine — the fresh/absent probes make
70
+ unbounded string domains decidable for these operators);
71
+ - `exists` (via the absent probe);
72
+ - the conjunctive envelope caveats: `expires`, `not_before` (empty-window detection), `rate`,
73
+ `max_blast_radius`, `reversibility_max`, `delegation_depth`, `budget_alloc`.
74
+
75
+ **Sound but over-approximated** — the result is reported with `approximate: true` and **fails safe**
76
+ (never claims *subsumed / always-deny / disjoint / equivalent / conforms* without proof; falls back to
77
+ *not-subsumed / reachable / not-disjoint / not-equivalent / non-conforming*):
78
+
79
+ - **`re:` regex resource matchers** — a regex relation (containment/overlap) is not decided; the
80
+ resource dimension cannot be soundly enumerated. (A regex is still evaluated *exactly* against a fully
81
+ concrete resource in `reachable`, where there is nothing to enumerate.)
82
+ - **cross-field `ref` conditions** — a comparison whose operand is another field (`a != env.b`); the two
83
+ fields co-vary and are not resolved statically.
84
+ - **whole-`action.params` conditions** (a condition on the container rather than a `.subfield`).
85
+ - **grid overflow** — a representative grid larger than `maxCombinations` (default 200,000); no
86
+ enumeration is performed.
87
+
88
+ The soundness contract: a proven verdict (`approximate` falsy) is correct for the fragment above; an
89
+ `approximate` verdict is never a false "safe". An external Z3 / SMT-LIB backend for the regex and
90
+ cross-field fragments is a possible future stretch; it is **not** required for the guarantees here.
@@ -0,0 +1,22 @@
1
+ import type { Caveat } from '@atlasauth/pca';
2
+ /**
3
+ * Static satisfiability of a PCA envelope caveat set (the leaf's cumulative, append-only, conjunctive
4
+ * caveats). This answers "is there ANY runtime context under which these caveats can all hold?" — the
5
+ * global gate on whether the authority can ever admit anything.
6
+ *
7
+ * The caveats constrain the runtime CaveatContext (now, blast radius, delegation depth, rate window,
8
+ * reversibility class), not the action's predicate fields. The analyzer treats the context as chosen
9
+ * favourably (the most permissive the authority could ever encounter) — the right stance for "what can
10
+ * this authority EVER do". The one caveat tied to the action itself is `reversibility_max`, whose cap
11
+ * is returned so the per-action check can enforce `class(action) <= cap`.
12
+ *
13
+ * Mirrors `envelopeCaveatEvaluator` in @atlasauth/pca (unknown/ malformed => fail closed).
14
+ */
15
+ export interface CaveatAnalysis {
16
+ /** Some context satisfies every caveat. */
17
+ sat: boolean;
18
+ reason?: string;
19
+ /** Strictest `reversibility_max` cap as an index into REVERSIBILITY_ORDER, if any caveat sets one. */
20
+ revCap?: number;
21
+ }
22
+ export declare function analyzeCaveats(caveats: readonly Caveat[] | undefined): CaveatAnalysis;
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.analyzeCaveats = analyzeCaveats;
4
+ const types_1 = require("./types");
5
+ function finite(v) {
6
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
7
+ }
8
+ function analyzeCaveats(caveats) {
9
+ if (!Array.isArray(caveats))
10
+ return { sat: false, reason: 'caveats are not an array' };
11
+ let notBeforeMax = -Infinity; // the action can only happen at now >= this
12
+ let expiresMin = Infinity; // ...and now < this
13
+ let revCap;
14
+ for (const cv of caveats) {
15
+ if (cv === null || typeof cv !== 'object' || typeof cv.type !== 'string') {
16
+ return { sat: false, reason: 'malformed caveat (not an object with a string type)' };
17
+ }
18
+ switch (cv.type) {
19
+ case 'expires': {
20
+ const at = finite(cv.at);
21
+ if (at === undefined)
22
+ return { sat: false, reason: 'expires.at is not a finite number' };
23
+ expiresMin = Math.min(expiresMin, at);
24
+ break;
25
+ }
26
+ case 'not_before': {
27
+ const at = finite(cv.at);
28
+ if (at === undefined)
29
+ return { sat: false, reason: 'not_before.at is not a finite number' };
30
+ notBeforeMax = Math.max(notBeforeMax, at);
31
+ break;
32
+ }
33
+ case 'rate': {
34
+ const max = finite(cv.max);
35
+ const per = finite(cv.per_secs);
36
+ if (max === undefined || per === undefined || per <= 0) {
37
+ return { sat: false, reason: 'rate caveat requires finite max and per_secs > 0' };
38
+ }
39
+ // With zero prior actions the count is 0, so the gate `0 < max` needs max >= 1.
40
+ if (max < 1)
41
+ return { sat: false, reason: `rate.max ${max} < 1 forbids every action` };
42
+ break;
43
+ }
44
+ case 'max_blast_radius': {
45
+ const max = finite(cv.max);
46
+ if (max === undefined)
47
+ return { sat: false, reason: 'max_blast_radius.max is not a finite number' };
48
+ // The least blast radius is 0, so this is satisfiable iff max >= 0.
49
+ if (max < 0)
50
+ return { sat: false, reason: 'max_blast_radius.max < 0 forbids every action' };
51
+ break;
52
+ }
53
+ case 'reversibility_max': {
54
+ const idx = types_1.REVERSIBILITY_ORDER.indexOf(String(cv.class));
55
+ if (idx < 0)
56
+ return { sat: false, reason: `reversibility_max.class '${String(cv.class)}' is unknown` };
57
+ revCap = revCap === undefined ? idx : Math.min(revCap, idx);
58
+ break;
59
+ }
60
+ case 'delegation_depth': {
61
+ const max = finite(cv.max);
62
+ if (max === undefined)
63
+ return { sat: false, reason: 'delegation_depth.max is not a finite number' };
64
+ // The least depth is 0, so this is satisfiable iff max >= 0.
65
+ if (max < 0)
66
+ return { sat: false, reason: 'delegation_depth.max < 0 forbids every action' };
67
+ break;
68
+ }
69
+ case 'budget_alloc': {
70
+ const lim = finite(cv.limit);
71
+ if (lim === undefined || lim < 0) {
72
+ return { sat: false, reason: 'budget_alloc.limit must be a finite number >= 0' };
73
+ }
74
+ break;
75
+ }
76
+ default:
77
+ return { sat: false, reason: `unknown caveat type '${cv.type}' (fail closed)` };
78
+ }
79
+ }
80
+ if (notBeforeMax !== -Infinity && expiresMin !== Infinity && notBeforeMax >= expiresMin) {
81
+ return {
82
+ sat: false,
83
+ reason: `not_before (${notBeforeMax}) >= expires (${expiresMin}): the validity window is empty`,
84
+ };
85
+ }
86
+ return { sat: true, revCap };
87
+ }
88
+ //# sourceMappingURL=caveats.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"caveats.js","sourceRoot":"","sources":["../src/caveats.ts"],"names":[],"mappings":";;AA4BA,wCAwEC;AAnGD,mCAA8C;AAuB9C,SAAS,MAAM,CAAC,CAAU;IACxB,OAAO,OAAO,CAAC,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AACrE,CAAC;AAED,SAAgB,cAAc,CAAC,OAAsC;IACnE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,0BAA0B,EAAE,CAAC;IACvF,IAAI,YAAY,GAAG,CAAC,QAAQ,CAAC,CAAC,4CAA4C;IAC1E,IAAI,UAAU,GAAG,QAAQ,CAAC,CAAC,oBAAoB;IAC/C,IAAI,MAA0B,CAAC;IAE/B,KAAK,MAAM,EAAE,IAAI,OAAO,EAAE,CAAC;QACzB,IAAI,EAAE,KAAK,IAAI,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,OAAO,EAAE,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YACzE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,qDAAqD,EAAE,CAAC;QACvF,CAAC;QACD,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC;YAChB,KAAK,SAAS,CAAC,CAAC,CAAC;gBACf,MAAM,EAAE,GAAG,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;gBACzB,IAAI,EAAE,KAAK,SAAS;oBAAE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,mCAAmC,EAAE,CAAC;gBACzF,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;gBACtC,MAAM;YACR,CAAC;YACD,KAAK,YAAY,CAAC,CAAC,CAAC;gBAClB,MAAM,EAAE,GAAG,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;gBACzB,IAAI,EAAE,KAAK,SAAS;oBAAE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,sCAAsC,EAAE,CAAC;gBAC5F,YAAY,GAAG,IAAI,CAAC,GAAG,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;gBAC1C,MAAM;YACR,CAAC;YACD,KAAK,MAAM,CAAC,CAAC,CAAC;gBACZ,MAAM,GAAG,GAAG,MAAM,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;gBAC3B,MAAM,GAAG,GAAG,MAAM,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC;gBAChC,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC;oBACvD,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,kDAAkD,EAAE,CAAC;gBACpF,CAAC;gBACD,gFAAgF;gBAChF,IAAI,GAAG,GAAG,CAAC;oBAAE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,GAAG,2BAA2B,EAAE,CAAC;gBACvF,MAAM;YACR,CAAC;YACD,KAAK,kBAAkB,CAAC,CAAC,CAAC;gBACxB,MAAM,GAAG,GAAG,MAAM,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;gBAC3B,IAAI,GAAG,KAAK,SAAS;oBAAE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,6CAA6C,EAAE,CAAC;gBACpG,oEAAoE;gBACpE,IAAI,GAAG,GAAG,CAAC;oBAAE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,+CAA+C,EAAE,CAAC;gBAC5F,MAAM;YACR,CAAC;YACD,KAAK,mBAAmB,CAAC,CAAC,CAAC;gBACzB,MAAM,GAAG,GAAI,2BAAyC,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;gBACjF,IAAI,GAAG,GAAG,CAAC;oBAAE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,4BAA4B,MAAM,CAAC,EAAE,CAAC,KAAK,CAAC,cAAc,EAAE,CAAC;gBACvG,MAAM,GAAG,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;gBAC5D,MAAM;YACR,CAAC;YACD,KAAK,kBAAkB,CAAC,CAAC,CAAC;gBACxB,MAAM,GAAG,GAAG,MAAM,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;gBAC3B,IAAI,GAAG,KAAK,SAAS;oBAAE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,6CAA6C,EAAE,CAAC;gBACpG,6DAA6D;gBAC7D,IAAI,GAAG,GAAG,CAAC;oBAAE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,+CAA+C,EAAE,CAAC;gBAC5F,MAAM;YACR,CAAC;YACD,KAAK,cAAc,CAAC,CAAC,CAAC;gBACpB,MAAM,GAAG,GAAG,MAAM,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC;gBAC7B,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC;oBACjC,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,iDAAiD,EAAE,CAAC;gBACnF,CAAC;gBACD,MAAM;YACR,CAAC;YACD;gBACE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,wBAAwB,EAAE,CAAC,IAAI,iBAAiB,EAAE,CAAC;QACpF,CAAC;IACH,CAAC;IAED,IAAI,YAAY,KAAK,CAAC,QAAQ,IAAI,UAAU,KAAK,QAAQ,IAAI,YAAY,IAAI,UAAU,EAAE,CAAC;QACxF,OAAO;YACL,GAAG,EAAE,KAAK;YACV,MAAM,EAAE,eAAe,YAAY,iBAAiB,UAAU,iCAAiC;SAChG,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;AAC/B,CAAC"}
@@ -0,0 +1,68 @@
1
+ import type { Caveat, Condition, Predicate } from '@atlasauth/pca';
2
+ import type { AnalyzerAction, PolicyInput } from './types';
3
+ type Cell = unknown;
4
+ /** Default cap on the representative grid size (product of all dimensions). */
5
+ export declare const DEFAULT_MAX_COMBINATIONS = 200000;
6
+ export interface PolicyModel {
7
+ predicates: Predicate[];
8
+ caveats: Caveat[];
9
+ caveatSat: boolean;
10
+ caveatReason?: string;
11
+ /** Strictest `reversibility_max` cap (index into REVERSIBILITY_ORDER), if any. */
12
+ revCap?: number;
13
+ /** Non-empty when the policy uses features the analyzer cannot decide exactly. */
14
+ undecidable: string[];
15
+ }
16
+ export declare function normalizePolicy(p: PolicyInput): {
17
+ predicates: Predicate[];
18
+ caveats: Caveat[];
19
+ };
20
+ export declare function toModel(p: PolicyInput): PolicyModel;
21
+ export interface Probes {
22
+ verbs: string[];
23
+ resources: string[];
24
+ /** `undefined` means "no reversibility dimension" (no policy sets a `reversibility_max`). */
25
+ revClasses: (string | undefined)[];
26
+ fields: {
27
+ path: string;
28
+ values: Cell[];
29
+ }[];
30
+ }
31
+ export interface ProbeInputs {
32
+ predicateSets: Predicate[][];
33
+ extraVerbs?: string[];
34
+ extraResources?: string[];
35
+ extraFieldConditions?: {
36
+ path: string;
37
+ cond: Condition;
38
+ }[];
39
+ /** Vary the reversibility class over all three classes (needed when any policy caps reversibility). */
40
+ varyReversibility: boolean;
41
+ }
42
+ export declare function collectProbes(inputs: ProbeInputs): Probes;
43
+ export declare function productSize(pr: Probes): number;
44
+ export interface Rep {
45
+ verb: string;
46
+ resource: string;
47
+ revClass: string | undefined;
48
+ /** Field path -> value, excluding absent fields. */
49
+ assignments: ReadonlyMap<string, unknown>;
50
+ }
51
+ export declare function representatives(pr: Probes): Generator<Rep>;
52
+ /** Does the policy admit this representative action? (predicate match AND static caveat gate.) */
53
+ export declare function admits(model: PolicyModel, rep: Rep): boolean;
54
+ /** Concrete admission of a fully-specified action (used by reachable's fast path; exact). */
55
+ export declare function admitsAction(model: PolicyModel, action: AnalyzerAction): boolean;
56
+ export declare function repToAction(rep: Rep): AnalyzerAction;
57
+ export interface SearchOutcome {
58
+ /** A representative for which the predicate returned true, if the search stopped on one. */
59
+ hit?: Rep;
60
+ /** The grid exceeded the configured bound; no enumeration was performed. */
61
+ overflow: boolean;
62
+ }
63
+ /**
64
+ * Walk the representative grid, stopping at the first rep for which `predicate` is true. Returns
65
+ * `overflow: true` (without walking) when the grid is larger than `max`.
66
+ */
67
+ export declare function search(pr: Probes, predicate: (rep: Rep) => boolean, max: number): SearchOutcome;
68
+ export {};
@@ -0,0 +1,375 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_MAX_COMBINATIONS = void 0;
4
+ exports.normalizePolicy = normalizePolicy;
5
+ exports.toModel = toModel;
6
+ exports.collectProbes = collectProbes;
7
+ exports.productSize = productSize;
8
+ exports.representatives = representatives;
9
+ exports.admits = admits;
10
+ exports.admitsAction = admitsAction;
11
+ exports.repToAction = repToAction;
12
+ exports.search = search;
13
+ const pca_1 = require("@atlasauth/pca");
14
+ const caveats_1 = require("./caveats");
15
+ const field_1 = require("./field");
16
+ const types_1 = require("./types");
17
+ /**
18
+ * The bounded decision engine. It builds a finite set of representative actions ("the grid") from the
19
+ * literals, thresholds, prefixes, verbs and resource matchers that appear in the policies (and, for
20
+ * intent conformance, the intent). Because PCA's `where` theory is a boolean combination of
21
+ * threshold/membership/prefix tests, each policy's admitted region is a union of grid cells, so one
22
+ * representative per cell is DISTINGUISHING: any two policies that differ on some action also differ on
23
+ * some representative. Evaluating the REAL `evaluatePredicates` at each representative (plus the static
24
+ * caveat gate) therefore decides reachability, subsumption, disjointness and equivalence EXACTLY for
25
+ * the decided fragment, and yields a concrete counterexample whenever a relation fails.
26
+ *
27
+ * Out-of-fragment features (`re:` regex resources, cross-field `ref` conditions) and grids larger than
28
+ * the bound are detected and reported as `approximate`, and every verdict then fails safe.
29
+ */
30
+ const ABSENT = Symbol('analyzer-absent-field');
31
+ const FRESH_VERB = '\u0000atlas-analyzer::fresh-verb\u0000';
32
+ const FRESH_RESOURCE = '\u0000atlas-analyzer::fresh-resource\u0000';
33
+ /** Default cap on the representative grid size (product of all dimensions). */
34
+ exports.DEFAULT_MAX_COMBINATIONS = 200_000;
35
+ function normalizePolicy(p) {
36
+ const predicates = Array.isArray(p.predicates) ? p.predicates : [];
37
+ const caveats = 'caveats' in p && Array.isArray(p.caveats) ? p.caveats : [];
38
+ return { predicates, caveats };
39
+ }
40
+ /**
41
+ * Collect over-approximation reasons from one condition tree. Boolean groupings (all_of/any_of/not)
42
+ * are DECIDED EXACTLY as long as every leaf uses a decidable op — the admitted region stays a boolean
43
+ * combination of the same per-field cells, so the representative grid remains distinguishing — so the
44
+ * grouping itself is not undecidable; only its hard leaves are. The genuinely-hard leaf ops
45
+ * (`like`/`matches`/`is_a`/`member_of`), a cross-field `ref`, and a whole-`action.params` condition
46
+ * force a conservative over-approximation (verdict fails safe).
47
+ */
48
+ function conditionUndecidable(c, reasons) {
49
+ if (c === null || typeof c !== 'object')
50
+ return;
51
+ if ((0, pca_1.isGroupCondition)(c)) {
52
+ if ('all_of' in c && Array.isArray(c.all_of))
53
+ for (const x of c.all_of)
54
+ conditionUndecidable(x, reasons);
55
+ else if ('any_of' in c && Array.isArray(c.any_of))
56
+ for (const x of c.any_of)
57
+ conditionUndecidable(x, reasons);
58
+ else if ('not' in c)
59
+ conditionUndecidable(c.not, reasons);
60
+ return;
61
+ }
62
+ if (!(0, pca_1.isLeafCondition)(c))
63
+ return;
64
+ if (c.ref !== undefined) {
65
+ reasons.add('cross-field `ref` condition — the relation between two fields is not resolved');
66
+ }
67
+ if (c.field === 'action.params') {
68
+ reasons.add('condition on the whole `action.params` object is not enumerated precisely');
69
+ }
70
+ if (c.op === 'like' || c.op === 'matches' || c.op === 'is_a' || c.op === 'member_of') {
71
+ reasons.add(`\`${c.op}\` condition — matched by over-approximation, not enumerated exactly`);
72
+ }
73
+ }
74
+ function detectUndecidable(predicates) {
75
+ const reasons = new Set();
76
+ for (const p of predicates) {
77
+ if (p === null || typeof p !== 'object')
78
+ continue;
79
+ const r = p.resource;
80
+ if (typeof r === 'string' && r.startsWith('re:')) {
81
+ reasons.add('re: regex resource matcher — resource dimension cannot be soundly enumerated');
82
+ }
83
+ if (Array.isArray(p.where))
84
+ for (const c of p.where)
85
+ conditionUndecidable(c, reasons);
86
+ }
87
+ return [...reasons];
88
+ }
89
+ function toModel(p) {
90
+ const { predicates, caveats } = normalizePolicy(p);
91
+ const ca = (0, caveats_1.analyzeCaveats)(caveats);
92
+ return {
93
+ predicates,
94
+ caveats,
95
+ caveatSat: ca.sat,
96
+ caveatReason: ca.reason,
97
+ revCap: ca.revCap,
98
+ undecidable: detectUndecidable(predicates),
99
+ };
100
+ }
101
+ function addVerbLiterals(c, into) {
102
+ const v = c.value;
103
+ if (c.op === 'eq' || c.op === 'ne' || c.op === 'prefix' || c.op === 'like' || c.op === 'is_a') {
104
+ if (typeof v === 'string')
105
+ into.add(v);
106
+ }
107
+ else if ((c.op === 'in' || c.op === 'nin') && Array.isArray(v)) {
108
+ for (const x of v)
109
+ if (typeof x === 'string')
110
+ into.add(x);
111
+ }
112
+ }
113
+ function collectProbes(inputs) {
114
+ const verbLiterals = new Set(inputs.extraVerbs ?? []);
115
+ const resourceLiterals = new Set();
116
+ const fieldConds = new Map();
117
+ const addFieldCond = (path, c) => {
118
+ const arr = fieldConds.get(path) ?? [];
119
+ arr.push(c);
120
+ fieldConds.set(path, arr);
121
+ };
122
+ const classifyResource = (r) => {
123
+ if (r === undefined || r === '*')
124
+ return; // universal: FRESH_RESOURCE represents it
125
+ if (r.startsWith('re:'))
126
+ return; // undecidable: FRESH_RESOURCE stands in (flagged on the model)
127
+ if (r.endsWith('*')) {
128
+ const body = r.slice(0, -1);
129
+ resourceLiterals.add(body);
130
+ resourceLiterals.add(body + 'x');
131
+ return;
132
+ }
133
+ resourceLiterals.add(r);
134
+ };
135
+ const handleCond = (c) => {
136
+ if (c === null || typeof c !== 'object')
137
+ return;
138
+ // Recurse through boolean groupings and bucket their leaves (keeps the grid distinguishing for
139
+ // nested boolean logic over decidable ops).
140
+ if ((0, pca_1.isGroupCondition)(c)) {
141
+ if ('all_of' in c && Array.isArray(c.all_of))
142
+ for (const x of c.all_of)
143
+ handleCond(x);
144
+ else if ('any_of' in c && Array.isArray(c.any_of))
145
+ for (const x of c.any_of)
146
+ handleCond(x);
147
+ else if ('not' in c)
148
+ handleCond(c.not);
149
+ return;
150
+ }
151
+ if (!(0, pca_1.isLeafCondition)(c))
152
+ return;
153
+ if (c.field === 'action.verb') {
154
+ addVerbLiterals(c, verbLiterals);
155
+ return;
156
+ }
157
+ if (c.field === 'action.resource') {
158
+ if (c.op === 'prefix' && typeof c.value === 'string')
159
+ classifyResource(c.value + '*');
160
+ else if (typeof c.value === 'string')
161
+ classifyResource(c.value);
162
+ else if (Array.isArray(c.value))
163
+ for (const x of c.value)
164
+ if (typeof x === 'string')
165
+ classifyResource(x);
166
+ return;
167
+ }
168
+ addFieldCond(c.field, c);
169
+ };
170
+ for (const list of inputs.predicateSets) {
171
+ for (const p of list) {
172
+ if (p === null || typeof p !== 'object')
173
+ continue;
174
+ if (typeof p.verb === 'string') {
175
+ if (p.verb !== '*')
176
+ verbLiterals.add(p.verb);
177
+ }
178
+ else if (Array.isArray(p.verb)) {
179
+ for (const v of p.verb)
180
+ if (typeof v === 'string' && v !== '*')
181
+ verbLiterals.add(v);
182
+ }
183
+ classifyResource(p.resource);
184
+ if (Array.isArray(p.where))
185
+ for (const c of p.where)
186
+ handleCond(c);
187
+ }
188
+ }
189
+ for (const r of inputs.extraResources ?? [])
190
+ classifyResource(r);
191
+ for (const ec of inputs.extraFieldConditions ?? [])
192
+ handleCond({ ...ec.cond, field: ec.path });
193
+ const fields = [...fieldConds.entries()].map(([path, conds]) => ({
194
+ path,
195
+ values: [...(0, field_1.fieldProbes)(conds), ABSENT],
196
+ }));
197
+ return {
198
+ verbs: [...verbLiterals, FRESH_VERB],
199
+ resources: [...resourceLiterals, FRESH_RESOURCE],
200
+ revClasses: inputs.varyReversibility ? [...types_1.REVERSIBILITY_ORDER] : [undefined],
201
+ fields,
202
+ };
203
+ }
204
+ function productSize(pr) {
205
+ let n = pr.verbs.length * pr.resources.length * pr.revClasses.length;
206
+ for (const f of pr.fields)
207
+ n *= f.values.length;
208
+ return n;
209
+ }
210
+ function* enumerateFields(fields, i, acc) {
211
+ if (i >= fields.length) {
212
+ yield new Map(acc);
213
+ return;
214
+ }
215
+ const f = fields[i];
216
+ if (f === undefined) {
217
+ yield new Map(acc);
218
+ return;
219
+ }
220
+ for (const v of f.values) {
221
+ if (v === ABSENT) {
222
+ acc.delete(f.path);
223
+ }
224
+ else {
225
+ acc.set(f.path, v);
226
+ }
227
+ yield* enumerateFields(fields, i + 1, acc);
228
+ }
229
+ acc.delete(f.path);
230
+ }
231
+ function* representatives(pr) {
232
+ for (const verb of pr.verbs) {
233
+ for (const resource of pr.resources) {
234
+ for (const revClass of pr.revClasses) {
235
+ for (const assignments of enumerateFields(pr.fields, 0, new Map())) {
236
+ yield { verb, resource, revClass, assignments };
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ function branch() {
243
+ return { leaf: false, children: new Map() };
244
+ }
245
+ function insert(root, segs, value) {
246
+ let node = root;
247
+ for (let i = 0; i < segs.length - 1; i++) {
248
+ const key = segs[i];
249
+ if (key === undefined || node.leaf)
250
+ return;
251
+ const next = node.children.get(key);
252
+ if (next === undefined || next.leaf) {
253
+ const created = branch();
254
+ node.children.set(key, created);
255
+ node = created;
256
+ }
257
+ else {
258
+ node = next;
259
+ }
260
+ }
261
+ const last = segs[segs.length - 1];
262
+ if (last !== undefined && !node.leaf)
263
+ node.children.set(last, { leaf: true, value });
264
+ }
265
+ function materialize(node) {
266
+ const out = {};
267
+ if (node.leaf)
268
+ return out;
269
+ for (const [key, child] of node.children) {
270
+ out[key] = child.leaf ? child.value : materialize(child);
271
+ }
272
+ return out;
273
+ }
274
+ function buildRoots(rep) {
275
+ const roots = new Map([
276
+ ['action', branch()],
277
+ ['subject', branch()],
278
+ ['env', branch()],
279
+ ]);
280
+ for (const [path, value] of rep.assignments) {
281
+ const segs = path.split('.');
282
+ const first = segs[0];
283
+ if (first === undefined || segs.length < 2)
284
+ continue;
285
+ const root = roots.get(first);
286
+ if (root === undefined)
287
+ continue; // only action / subject / env roots are addressable
288
+ insert(root, segs.slice(1), value);
289
+ }
290
+ const actionTree = roots.get('action');
291
+ const subjectTree = roots.get('subject');
292
+ const envTree = roots.get('env');
293
+ const action = { verb: rep.verb, resource: rep.resource, params: {} };
294
+ if (actionTree !== undefined && !actionTree.leaf) {
295
+ for (const [key, child] of actionTree.children) {
296
+ if (key === 'verb' || key === 'resource')
297
+ continue; // fixed from the rep
298
+ if (key === 'params') {
299
+ // `action.params.*` branches materialize to a typed record; a whole-`params` leaf is flagged
300
+ // undecidable upstream, so it is safe to ignore here (params stays an empty, present object).
301
+ if (!child.leaf)
302
+ action.params = materialize(child);
303
+ }
304
+ else {
305
+ action[key] = child.leaf ? child.value : materialize(child);
306
+ }
307
+ }
308
+ }
309
+ if (rep.revClass !== undefined)
310
+ action.reversibility_class = rep.revClass;
311
+ const subject = subjectTree === undefined || subjectTree.leaf ? {} : materialize(subjectTree);
312
+ const env = envTree === undefined || envTree.leaf ? {} : materialize(envTree);
313
+ return { action, subject, env };
314
+ }
315
+ function contextFromRoots(roots) {
316
+ return { action: roots.action, subject: roots.subject, env: roots.env };
317
+ }
318
+ function reversibilityOk(model, revClass) {
319
+ if (model.revCap === undefined)
320
+ return true;
321
+ if (revClass === undefined)
322
+ return false; // a reversibility_max caveat denies an action with no class
323
+ const idx = types_1.REVERSIBILITY_ORDER.indexOf(revClass);
324
+ return idx >= 0 && idx <= model.revCap;
325
+ }
326
+ /** Does the policy admit this representative action? (predicate match AND static caveat gate.) */
327
+ function admits(model, rep) {
328
+ if (!model.caveatSat)
329
+ return false;
330
+ if (!reversibilityOk(model, rep.revClass))
331
+ return false;
332
+ const ctx = contextFromRoots(buildRoots(rep));
333
+ return (0, pca_1.evaluatePredicates)(model.predicates, ctx).allowed;
334
+ }
335
+ /** Concrete admission of a fully-specified action (used by reachable's fast path; exact). */
336
+ function admitsAction(model, action) {
337
+ if (!model.caveatSat)
338
+ return false;
339
+ if (!reversibilityOk(model, action.reversibilityClass))
340
+ return false;
341
+ const act = { verb: action.verb, resource: action.resource, params: { ...(action.params ?? {}) } };
342
+ if (action.reversibilityClass !== undefined)
343
+ act.reversibility_class = action.reversibilityClass;
344
+ const ctx = {
345
+ action: act,
346
+ subject: { ...(action.subject ?? {}) },
347
+ env: { ...(action.env ?? {}) },
348
+ };
349
+ return (0, pca_1.evaluatePredicates)(model.predicates, ctx).allowed;
350
+ }
351
+ function repToAction(rep) {
352
+ const roots = buildRoots(rep);
353
+ const action = { verb: rep.verb, resource: rep.resource, params: { ...roots.action.params } };
354
+ if (rep.revClass !== undefined)
355
+ action.reversibilityClass = rep.revClass;
356
+ if (Object.keys(roots.subject).length > 0)
357
+ action.subject = roots.subject;
358
+ if (Object.keys(roots.env).length > 0)
359
+ action.env = roots.env;
360
+ return action;
361
+ }
362
+ /**
363
+ * Walk the representative grid, stopping at the first rep for which `predicate` is true. Returns
364
+ * `overflow: true` (without walking) when the grid is larger than `max`.
365
+ */
366
+ function search(pr, predicate, max) {
367
+ if (productSize(pr) > max)
368
+ return { overflow: true };
369
+ for (const rep of representatives(pr)) {
370
+ if (predicate(rep))
371
+ return { hit: rep, overflow: false };
372
+ }
373
+ return { overflow: false };
374
+ }
375
+ //# sourceMappingURL=enumerate.js.map