flux-sim-engine 0.2.0__py3-none-any.whl
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.
- flux_engine/__init__.py +22 -0
- flux_engine/_links.py +55 -0
- flux_engine/_vendor/__init__.py +5 -0
- flux_engine/_vendor/flux_spec/NOTICE +19 -0
- flux_engine/_vendor/flux_spec/VENDOR-NOTES.md +104 -0
- flux_engine/_vendor/flux_spec/__init__.py +9 -0
- flux_engine/_vendor/flux_spec/bundle.py +410 -0
- flux_engine/_vendor/flux_spec/enforce.py +316 -0
- flux_engine/_vendor/flux_spec/schema/flux-enforcement-0.5.0.json +121 -0
- flux_engine/_vendor/flux_spec/schema/flux-manifest-0.5.0.json +153 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.2.0.json +465 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.3.0.json +876 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.4.0.json +1601 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.4.1.json +1668 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.5.0.json +1669 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-latest.json +1669 -0
- flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.4.0.json +795 -0
- flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.4.1.json +852 -0
- flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.5.0.json +852 -0
- flux_engine/_vendor/flux_spec/validate.py +537 -0
- flux_engine/_vendor/flux_spec/vectors/enforcement-vectors.json +935 -0
- flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.3.json +5234 -0
- flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.4.json +5437 -0
- flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.5.json +5498 -0
- flux_engine/_vocabulary.py +141 -0
- flux_engine/accuracy/__init__.py +5 -0
- flux_engine/accuracy/_scoring/__init__.py +24 -0
- flux_engine/accuracy/accuracy_record.py +96 -0
- flux_engine/accuracy/policy.py +158 -0
- flux_engine/accuracy/records.py +264 -0
- flux_engine/accuracy/store.py +169 -0
- flux_engine/api/__init__.py +163 -0
- flux_engine/api/accuracy.py +209 -0
- flux_engine/api/agentic.py +134 -0
- flux_engine/api/calibration.py +125 -0
- flux_engine/api/claims.py +93 -0
- flux_engine/api/digests.py +73 -0
- flux_engine/api/emit.py +78 -0
- flux_engine/api/expressions.py +72 -0
- flux_engine/api/fidelity.py +110 -0
- flux_engine/api/lineage.py +78 -0
- flux_engine/api/stages.py +242 -0
- flux_engine/api/verdict.py +108 -0
- flux_engine/calibrate/__init__.py +5 -0
- flux_engine/calibrate/declared.py +253 -0
- flux_engine/calibrate/provenance.py +113 -0
- flux_engine/cli/__init__.py +2146 -0
- flux_engine/cli/__main__.py +17 -0
- flux_engine/cli/_documents.py +226 -0
- flux_engine/cli/_hints.py +118 -0
- flux_engine/cli/_init.py +152 -0
- flux_engine/cli/_progress.py +124 -0
- flux_engine/concordance/__init__.py +127 -0
- flux_engine/concordance/boundary/__init__.py +78 -0
- flux_engine/concordance/boundary/merkle.py +287 -0
- flux_engine/concordance/boundary/slice.py +429 -0
- flux_engine/concordance/boundary/statistic.py +561 -0
- flux_engine/concordance/boundary/store.py +334 -0
- flux_engine/concordance/boundary/vault.py +357 -0
- flux_engine/concordance/compiler.py +542 -0
- flux_engine/concordance/errors.py +189 -0
- flux_engine/concordance/gate.py +525 -0
- flux_engine/concordance/predicate.py +1217 -0
- flux_engine/concordance/record.py +554 -0
- flux_engine/concordance/verifier.py +583 -0
- flux_engine/core/__init__.py +5 -0
- flux_engine/core/artifact.py +99 -0
- flux_engine/core/canonical.py +369 -0
- flux_engine/core/clock.py +437 -0
- flux_engine/core/digest.py +288 -0
- flux_engine/core/errors.py +277 -0
- flux_engine/core/fixedpoint.py +929 -0
- flux_engine/core/lrm.py +158 -0
- flux_engine/core/rng.py +736 -0
- flux_engine/core/seedtree.py +891 -0
- flux_engine/core/trace.py +423 -0
- flux_engine/describe.py +207 -0
- flux_engine/engine.py +257 -0
- flux_engine/evidence/__init__.py +5 -0
- flux_engine/evidence/pack.py +411 -0
- flux_engine/evidence/report.py +243 -0
- flux_engine/expr/__init__.py +133 -0
- flux_engine/expr/ast.py +312 -0
- flux_engine/expr/attrs.py +386 -0
- flux_engine/expr/check.py +278 -0
- flux_engine/expr/compile.py +322 -0
- flux_engine/expr/errors.py +256 -0
- flux_engine/expr/lexer.py +297 -0
- flux_engine/expr/parser.py +313 -0
- flux_engine/expr/types.py +177 -0
- flux_engine/generator/__init__.py +97 -0
- flux_engine/generator/families.py +325 -0
- flux_engine/generator/paths.py +227 -0
- flux_engine/generator/rows.py +918 -0
- flux_engine/generator/statistics.py +524 -0
- flux_engine/governance/__init__.py +87 -0
- flux_engine/governance/audit.py +307 -0
- flux_engine/governance/gate.py +334 -0
- flux_engine/governance/skills.py +220 -0
- flux_engine/governance/sovereignty.py +251 -0
- flux_engine/lineage/__init__.py +5 -0
- flux_engine/lineage/emitter.py +398 -0
- flux_engine/py.typed +0 -0
- flux_engine/runtime/__init__.py +153 -0
- flux_engine/runtime/campaign.py +407 -0
- flux_engine/runtime/cast.py +283 -0
- flux_engine/runtime/detector.py +222 -0
- flux_engine/runtime/errors.py +58 -0
- flux_engine/runtime/experiment.py +311 -0
- flux_engine/runtime/journey.py +383 -0
- flux_engine/runtime/jurisdiction.py +72 -0
- flux_engine/runtime/payloads.py +124 -0
- flux_engine/runtime/signals.py +334 -0
- flux_engine/runtime/temperature.py +156 -0
- flux_engine/seam/__init__.py +5 -0
- flux_engine/seam/attest.py +290 -0
- flux_engine/seam/conform.py +448 -0
- flux_engine/seam/contract.py +605 -0
- flux_engine/seam/emit.py +488 -0
- flux_engine/seam/mapping.py +299 -0
- flux_engine/seam/resolve.py +1087 -0
- flux_engine/seam/sinks.py +157 -0
- flux_engine/spec/__init__.py +134 -0
- flux_engine/spec/bundle.py +25 -0
- flux_engine/spec/enforce.py +31 -0
- flux_engine/spec/validate.py +28 -0
- flux_engine/stages/__init__.py +30 -0
- flux_engine/stages/apply.py +447 -0
- flux_engine/stages/errors.py +315 -0
- flux_engine/stages/layout.py +292 -0
- flux_engine/stages/pipeline.py +600 -0
- flux_engine/stages/plan.py +241 -0
- flux_engine/stages/replay.py +83 -0
- flux_engine/stages/run.py +208 -0
- flux_engine/stages/ship.py +934 -0
- flux_engine/stages/validate.py +93 -0
- flux_engine/stages/verify.py +1129 -0
- flux_engine/starter/__init__.py +161 -0
- flux_engine/starter/bundle/detector.flux.yml +15 -0
- flux_engine/starter/bundle/journey.flux.yml +23 -0
- flux_engine/starter/bundle/module.flux.yml +16 -0
- flux_engine/starter/bundle/payment-recovery.fluid.yml +30 -0
- flux_engine/starter/bundle/persona.flux.yml +26 -0
- flux_engine/starter/bundle/signal.flux.yml +13 -0
- flux_engine/starter/bundle/simulation.flux.yml +29 -0
- flux_engine/starter/bundle/taxonomy.flux.yml +17 -0
- flux_engine/starter/bundle/treatment.flux.yml +13 -0
- flux_engine/starter/bundle/world.flux.yml +20 -0
- flux_engine/verticals/__init__.py +85 -0
- flux_engine/verticals/telco.py +89 -0
- flux_engine/world/__init__.py +89 -0
- flux_engine/world/derived.py +195 -0
- flux_engine/world/errors.py +41 -0
- flux_engine/world/families.py +260 -0
- flux_engine/world/population.py +602 -0
- flux_engine/world/tracing.py +66 -0
- flux_engine/world/values.py +121 -0
- flux_sim_engine-0.2.0.dist-info/METADATA +108 -0
- flux_sim_engine-0.2.0.dist-info/RECORD +165 -0
- flux_sim_engine-0.2.0.dist-info/WHEEL +5 -0
- flux_sim_engine-0.2.0.dist-info/entry_points.txt +2 -0
- flux_sim_engine-0.2.0.dist-info/licenses/LICENSE +202 -0
- flux_sim_engine-0.2.0.dist-info/licenses/NOTICE +10 -0
- flux_sim_engine-0.2.0.dist-info/licenses/flux_engine/_vendor/flux_spec/NOTICE +19 -0
- flux_sim_engine-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""FLUX reference policy gate (RFC-04).
|
|
3
|
+
|
|
4
|
+
The normative enforcement decision contract, implemented. A conformant gate
|
|
5
|
+
answers one question — *may this AI call proceed under this agentPolicy?* —
|
|
6
|
+
and answers it identically everywhere:
|
|
7
|
+
|
|
8
|
+
decide(policy, request, skill=None) -> decision
|
|
9
|
+
|
|
10
|
+
Checks run in a fixed, normative order and the first failure wins:
|
|
11
|
+
|
|
12
|
+
0. INVALID_POLICY the policy itself is off-spec (fail closed)
|
|
13
|
+
1. INVALID_REQUEST the request is malformed
|
|
14
|
+
2. MODEL_NOT_ALLOWED model outside agentPolicy.allowedModels
|
|
15
|
+
3. USE_CASE_DENIED use case listed in deniedUseCases
|
|
16
|
+
4. USE_CASE_NOT_ALLOWED allowedUseCases declared and use case absent
|
|
17
|
+
5. PURPOSE_REQUIRED purposeLimitation on (default true) and no purpose
|
|
18
|
+
6. TOKEN_BUDGET_EXCEEDED tokens above the effective budget
|
|
19
|
+
|
|
20
|
+
Three properties make this contract portable rather than merely implementable:
|
|
21
|
+
|
|
22
|
+
**Numbers are values, not Python types.** JSON Schema `"type": "integer"`
|
|
23
|
+
matches `200000.0` as readily as `200000`, so a gate that tests
|
|
24
|
+
`isinstance(x, int)` silently stops enforcing budgets on a schema-valid
|
|
25
|
+
policy. Every integer here is read through `as_int()`, which accepts either
|
|
26
|
+
spelling and rejects booleans.
|
|
27
|
+
|
|
28
|
+
**Strings are compared after normalisation.** `useCase` is caller-supplied,
|
|
29
|
+
so a deny-list matched by raw equality is advisory: `Advertising`,
|
|
30
|
+
`advertising ` and a Cyrillic homoglyph all slip past. Identifiers are
|
|
31
|
+
NFC-normalised and stripped before comparison (case-sensitively — see
|
|
32
|
+
`normalize()`).
|
|
33
|
+
|
|
34
|
+
**Digests are canonical.** `policyDigest` is the SHA-256 of the policy
|
|
35
|
+
canonicalised per RFC 8785 (JCS), so a Python gate and a JavaScript gate
|
|
36
|
+
agree byte-for-byte. It digests the *document's* policy, never a synthesised
|
|
37
|
+
one; when a skill narrows the budget the decision records `skillRef` and
|
|
38
|
+
`effectiveBudget` separately, so an auditor can join a decision back to the
|
|
39
|
+
document that authorised it and still see what actually applied.
|
|
40
|
+
|
|
41
|
+
Prior art: the evaluation model is deliberately the one adopters already know
|
|
42
|
+
from Cedar (docs.cedarpolicy.com) and Open Policy Agent — **default deny**,
|
|
43
|
+
and **forbid overrides permit** (here: deniedUseCases beats allowedUseCases).
|
|
44
|
+
We diverge from Cedar's `{decision, diagnostics:{reason, errors}}` shape in
|
|
45
|
+
one respect: Cedar's `reason` lists the policy ids that matched, which is
|
|
46
|
+
right for a policy *language* with arbitrary rules, whereas FLUX has a fixed
|
|
47
|
+
check contract. A closed `reasonCode` enum is what makes conformance testable
|
|
48
|
+
— two gates must agree not just on the verdict but on *why*.
|
|
49
|
+
|
|
50
|
+
Usage:
|
|
51
|
+
python3 scripts/enforce.py # run the conformance vectors
|
|
52
|
+
python3 scripts/enforce.py --vectors FILE # run a specific vector file
|
|
53
|
+
|
|
54
|
+
Exit code 0 = every vector matched. Vendors demonstrate RFC-04 conformance by
|
|
55
|
+
running their own gate against tests/enforcement-vectors.json — which is
|
|
56
|
+
itself guarded by a negative test (tests/test_regression.py) proving that a
|
|
57
|
+
gate with known defects fails the suite.
|
|
58
|
+
|
|
59
|
+
Requires: pip install rfc8785 (pure Python, no dependencies — the Trail of
|
|
60
|
+
Bits implementation of RFC 8785).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import argparse
|
|
66
|
+
import hashlib
|
|
67
|
+
import json
|
|
68
|
+
import sys
|
|
69
|
+
import unicodedata
|
|
70
|
+
from pathlib import Path
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
import rfc8785
|
|
74
|
+
except ImportError: # pragma: no cover
|
|
75
|
+
sys.exit("enforce.py requires: pip install rfc8785")
|
|
76
|
+
|
|
77
|
+
# VENDOR EDIT (see VENDOR-NOTES.md): one level shallower (see validate.py),
|
|
78
|
+
# and the vectors ship under vectors/ in the vendored layout, not tests/.
|
|
79
|
+
REPO = Path(__file__).resolve().parent
|
|
80
|
+
DEFAULT_VECTORS = REPO / "vectors" / "enforcement-vectors.json"
|
|
81
|
+
|
|
82
|
+
# JSON numbers are IEEE-754 doubles once they cross a language boundary, so
|
|
83
|
+
# integers above 2^53-1 cannot be exchanged (or canonicalised per RFC 8785)
|
|
84
|
+
# without silent precision loss. Values beyond it are rejected rather than
|
|
85
|
+
# rounded — a budget nobody can agree on is worse than no budget.
|
|
86
|
+
MAX_SAFE_INTEGER = 9007199254740991
|
|
87
|
+
|
|
88
|
+
REASONS = {
|
|
89
|
+
"ALLOWED": "the call satisfies every constraint of the policy",
|
|
90
|
+
"INVALID_POLICY": "agentPolicy is off-spec: {detail}",
|
|
91
|
+
"INVALID_REQUEST": "request is malformed: {detail}",
|
|
92
|
+
"MODEL_NOT_ALLOWED": "model {model!r} is not in agentPolicy.allowedModels",
|
|
93
|
+
"USE_CASE_DENIED": "use case {useCase!r} is listed in agentPolicy.deniedUseCases",
|
|
94
|
+
"USE_CASE_NOT_ALLOWED": "use case {useCase!r} is not in agentPolicy.allowedUseCases",
|
|
95
|
+
"PURPOSE_REQUIRED": "agentPolicy.purposeLimitation requires a purpose on every call",
|
|
96
|
+
"TOKEN_BUDGET_EXCEEDED": "requested {tokens} tokens, effective budget is {budget}",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def as_int(value):
|
|
101
|
+
"""The JSON notion of an integer, not Python's.
|
|
102
|
+
|
|
103
|
+
JSON Schema `"type": "integer"` accepts any number with a zero fractional
|
|
104
|
+
part, so `200000.0` is a valid tokenBudget and a JS gate will parse it to
|
|
105
|
+
a float. Booleans are rejected outright — `isinstance(True, int)` is True
|
|
106
|
+
in Python, which would otherwise read `tokenBudget: true` as a budget of 1.
|
|
107
|
+
Returns None when the value is not an integer.
|
|
108
|
+
"""
|
|
109
|
+
if isinstance(value, bool):
|
|
110
|
+
return None
|
|
111
|
+
if isinstance(value, int):
|
|
112
|
+
return value
|
|
113
|
+
if isinstance(value, float) and value.is_integer():
|
|
114
|
+
return int(value)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def normalize(value: str) -> str:
|
|
119
|
+
"""Normalise an identifier before comparison.
|
|
120
|
+
|
|
121
|
+
NFC (so a decomposed 'café' matches a composed one) plus surrounding
|
|
122
|
+
whitespace stripped (so 'advertising ' cannot slip past a deny-list).
|
|
123
|
+
Matching is case-SENSITIVE: folding case would make `Model-A` and
|
|
124
|
+
`model-a` the same identifier, which is wrong for model names that really
|
|
125
|
+
do differ. Policies should not declare identifiers differing only by case.
|
|
126
|
+
"""
|
|
127
|
+
return unicodedata.normalize("NFC", value).strip()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def canonical_bytes(value) -> bytes:
|
|
131
|
+
"""RFC 8785 (JCS) canonical serialisation — the digest's input."""
|
|
132
|
+
return rfc8785.dumps(value)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def policy_digest(policy) -> str:
|
|
136
|
+
"""SHA-256 over the JCS canonicalisation of the policy.
|
|
137
|
+
|
|
138
|
+
Canonical means cross-implementation: JCS fixes key order, number
|
|
139
|
+
spelling (200000.0 and 200000 serialise identically) and string escaping
|
|
140
|
+
(raw UTF-8, not \\uXXXX), so a Python gate and a JavaScript gate produce
|
|
141
|
+
the same digest for the same policy.
|
|
142
|
+
"""
|
|
143
|
+
return "sha256:" + hashlib.sha256(canonical_bytes(policy)).hexdigest()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _string_list(value):
|
|
147
|
+
return isinstance(value, list) and value and all(isinstance(v, str) and v.strip() for v in value)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def policy_problem(policy) -> str | None:
|
|
151
|
+
"""Why this policy is off-spec, or None. Mirrors $defs/agentPolicy.
|
|
152
|
+
|
|
153
|
+
A gate MUST reject an off-spec policy rather than enforce a degraded
|
|
154
|
+
reading of it: a missing or non-integer tokenBudget must never mean
|
|
155
|
+
"no budget".
|
|
156
|
+
"""
|
|
157
|
+
if not isinstance(policy, dict):
|
|
158
|
+
return "policy must be an object"
|
|
159
|
+
if not _string_list(policy.get("allowedModels")):
|
|
160
|
+
return "allowedModels must be a non-empty array of non-empty strings"
|
|
161
|
+
budget = as_int(policy.get("tokenBudget"))
|
|
162
|
+
if budget is None or budget < 1:
|
|
163
|
+
return "tokenBudget must be an integer of at least 1"
|
|
164
|
+
if budget > MAX_SAFE_INTEGER:
|
|
165
|
+
return f"tokenBudget exceeds the JSON-safe integer domain ({MAX_SAFE_INTEGER})"
|
|
166
|
+
for field in ("allowedUseCases", "deniedUseCases"):
|
|
167
|
+
if field in policy and not _string_list(policy[field]):
|
|
168
|
+
return f"{field}, when present, must be a non-empty array of non-empty strings"
|
|
169
|
+
if "purposeLimitation" in policy and not isinstance(policy["purposeLimitation"], bool):
|
|
170
|
+
return "purposeLimitation must be a boolean"
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def request_problem(request) -> str | None:
|
|
175
|
+
if not isinstance(request, dict):
|
|
176
|
+
return "request must be an object"
|
|
177
|
+
for field in ("model", "useCase"):
|
|
178
|
+
value = request.get(field)
|
|
179
|
+
if not isinstance(value, str) or not value.strip():
|
|
180
|
+
return f"{field} must be a non-empty string"
|
|
181
|
+
tokens = as_int(request.get("tokens"))
|
|
182
|
+
if tokens is None:
|
|
183
|
+
return "tokens must be an integer"
|
|
184
|
+
if tokens < 0:
|
|
185
|
+
return "tokens must not be negative"
|
|
186
|
+
if tokens > MAX_SAFE_INTEGER:
|
|
187
|
+
return f"tokens exceeds the JSON-safe integer domain ({MAX_SAFE_INTEGER})"
|
|
188
|
+
if "purpose" in request and not isinstance(request["purpose"], str):
|
|
189
|
+
return "purpose, when present, must be a string"
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def effective_budget(policy, skill=None) -> int:
|
|
194
|
+
"""The budget that actually applies to this call.
|
|
195
|
+
|
|
196
|
+
A skill NARROWS: the effective budget is min(skill, policy). It can never
|
|
197
|
+
widen one — the document validator rejects a skill budget above its
|
|
198
|
+
policy's, and this is the belt to that braces.
|
|
199
|
+
"""
|
|
200
|
+
budget = as_int(policy.get("tokenBudget"))
|
|
201
|
+
skill_budget = as_int((skill or {}).get("tokenBudget"))
|
|
202
|
+
if skill_budget is None:
|
|
203
|
+
return budget
|
|
204
|
+
return min(skill_budget, budget)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def effective_policy(policy: dict, skill: dict | None = None) -> dict:
|
|
208
|
+
"""Back-compat helper: the policy with its budget narrowed by `skill`.
|
|
209
|
+
|
|
210
|
+
Prefer passing `skill` to decide() directly — that keeps policyDigest
|
|
211
|
+
bound to the document's own policy instead of a synthesised one.
|
|
212
|
+
"""
|
|
213
|
+
if not skill:
|
|
214
|
+
return policy
|
|
215
|
+
narrowed = dict(policy)
|
|
216
|
+
budget = effective_budget(policy, skill)
|
|
217
|
+
if budget is not None:
|
|
218
|
+
narrowed["tokenBudget"] = budget
|
|
219
|
+
return narrowed
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _decision(allow, code, policy, request, skill=None, budget=None, **fmt):
|
|
223
|
+
fields = {**(request if isinstance(request, dict) else {}), "budget": budget, **fmt}
|
|
224
|
+
decision = {
|
|
225
|
+
"allow": allow,
|
|
226
|
+
"reasonCode": code,
|
|
227
|
+
"reason": REASONS[code].format(**fields),
|
|
228
|
+
"policyDigest": policy_digest(policy) if isinstance(policy, dict) else policy_digest({}),
|
|
229
|
+
"request": request if isinstance(request, dict) else {},
|
|
230
|
+
}
|
|
231
|
+
if budget is not None:
|
|
232
|
+
decision["effectiveBudget"] = budget
|
|
233
|
+
if skill and isinstance(skill.get("name"), str):
|
|
234
|
+
decision["skillRef"] = skill["name"]
|
|
235
|
+
return decision
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def decide(policy: dict, request: dict, skill: dict | None = None) -> dict:
|
|
239
|
+
"""Evaluate one call against one agentPolicy. Pure and deterministic.
|
|
240
|
+
|
|
241
|
+
`policy` is the document's agentPolicy — pass `skill` rather than a
|
|
242
|
+
pre-narrowed policy, so the decision's digest identifies the authorising
|
|
243
|
+
document and `effectiveBudget`/`skillRef` record what actually applied.
|
|
244
|
+
"""
|
|
245
|
+
problem = policy_problem(policy)
|
|
246
|
+
if problem:
|
|
247
|
+
return _decision(False, "INVALID_POLICY", policy, request, skill, detail=problem)
|
|
248
|
+
|
|
249
|
+
problem = request_problem(request)
|
|
250
|
+
if problem:
|
|
251
|
+
return _decision(False, "INVALID_REQUEST", policy, request, skill, detail=problem)
|
|
252
|
+
|
|
253
|
+
budget = effective_budget(policy, skill)
|
|
254
|
+
model = normalize(request["model"])
|
|
255
|
+
use_case = normalize(request["useCase"])
|
|
256
|
+
|
|
257
|
+
if model not in {normalize(m) for m in policy["allowedModels"]}:
|
|
258
|
+
return _decision(False, "MODEL_NOT_ALLOWED", policy, request, skill, budget=budget)
|
|
259
|
+
|
|
260
|
+
if use_case in {normalize(u) for u in policy.get("deniedUseCases") or []}:
|
|
261
|
+
return _decision(False, "USE_CASE_DENIED", policy, request, skill, budget=budget)
|
|
262
|
+
|
|
263
|
+
if "allowedUseCases" in policy and use_case not in {normalize(u) for u in policy["allowedUseCases"]}:
|
|
264
|
+
return _decision(False, "USE_CASE_NOT_ALLOWED", policy, request, skill, budget=budget)
|
|
265
|
+
|
|
266
|
+
if policy.get("purposeLimitation", True) and not (request.get("purpose") or "").strip():
|
|
267
|
+
return _decision(False, "PURPOSE_REQUIRED", policy, request, skill, budget=budget)
|
|
268
|
+
|
|
269
|
+
if as_int(request["tokens"]) > budget:
|
|
270
|
+
return _decision(False, "TOKEN_BUDGET_EXCEEDED", policy, request, skill, budget=budget)
|
|
271
|
+
|
|
272
|
+
return _decision(True, "ALLOWED", policy, request, skill, budget=budget)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def run_vectors(path: Path, gate=decide, label: str = "reference gate") -> tuple[int, list[str]]:
|
|
276
|
+
"""Run a vector file against `gate`. Returns (failures, messages).
|
|
277
|
+
|
|
278
|
+
Every vector asserts allow, reasonCode, and — where the vector pins one —
|
|
279
|
+
policyDigest, so a gate cannot pass by guessing verdicts while computing
|
|
280
|
+
digests wrongly (or not at all).
|
|
281
|
+
"""
|
|
282
|
+
vectors = json.loads(path.read_text())
|
|
283
|
+
messages = []
|
|
284
|
+
for v in vectors["vectors"]:
|
|
285
|
+
decision = gate(v["policy"], v["request"], v.get("skill"))
|
|
286
|
+
expect = v["expect"]
|
|
287
|
+
if decision.get("allow") != expect["allow"] or decision.get("reasonCode") != expect["reasonCode"]:
|
|
288
|
+
messages.append(
|
|
289
|
+
f"{v['name']}: expected {expect['reasonCode']} (allow={expect['allow']}), "
|
|
290
|
+
f"got {decision.get('reasonCode')} (allow={decision.get('allow')})"
|
|
291
|
+
)
|
|
292
|
+
continue
|
|
293
|
+
if "policyDigest" in expect and decision.get("policyDigest") != expect["policyDigest"]:
|
|
294
|
+
messages.append(f"{v['name']}: policyDigest mismatch — expected {expect['policyDigest']}, got {decision.get('policyDigest')}")
|
|
295
|
+
if "effectiveBudget" in expect and decision.get("effectiveBudget") != expect["effectiveBudget"]:
|
|
296
|
+
messages.append(f"{v['name']}: effectiveBudget mismatch — expected {expect['effectiveBudget']}, got {decision.get('effectiveBudget')}")
|
|
297
|
+
return len(messages), messages
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def main(argv=None) -> int:
|
|
301
|
+
ap = argparse.ArgumentParser(description="FLUX reference policy gate (RFC-04)")
|
|
302
|
+
ap.add_argument("--vectors", type=Path, default=DEFAULT_VECTORS)
|
|
303
|
+
args = ap.parse_args(argv)
|
|
304
|
+
total = len(json.loads(args.vectors.read_text())["vectors"])
|
|
305
|
+
failures, messages = run_vectors(args.vectors)
|
|
306
|
+
if failures:
|
|
307
|
+
print(f"FAIL — {failures}/{total} enforcement vectors failed")
|
|
308
|
+
for m in messages:
|
|
309
|
+
print(" ", m)
|
|
310
|
+
return 1
|
|
311
|
+
print(f"ok — {total} enforcement vectors green")
|
|
312
|
+
return 0
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
if __name__ == "__main__":
|
|
316
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://agenticstiger.github.io/flux/schema/flux-enforcement-0.5.0.json",
|
|
4
|
+
"title": "FLUX Enforcement Decision Contract",
|
|
5
|
+
"description": "RFC-04: the normative runtime contract every conformant policy gate implements. Input: an agentPolicy (from any FLUX document), a request, and optionally the skill binding the call is made through. Output: a decision. Checks run in a fixed, normative order \u2014 INVALID_POLICY, INVALID_REQUEST, MODEL_NOT_ALLOWED, USE_CASE_DENIED, USE_CASE_NOT_ALLOWED, PURPOSE_REQUIRED, TOKEN_BUDGET_EXCEEDED \u2014 and the first failure wins, so identical inputs yield identical decisions on every implementation. NUMBERS: integers follow JSON semantics, not a host language's types \u2014 200000.0 is the integer 200000, and booleans are never integers. Values above 2^53-1 (9007199254740991) are rejected, because they cannot cross a JSON boundary or be canonicalised per RFC 8785 without silent precision loss. STRINGS: model and useCase identifiers are NFC-normalised and surrounding whitespace stripped before comparison; matching is case-sensitive. POLICIES: a gate MUST reject an off-spec policy with INVALID_POLICY rather than enforce a degraded reading of it \u2014 a missing or non-integer tokenBudget is never an unlimited budget. SKILLS: when a call is made through a skill, the effective budget is min(skill.tokenBudget, policy.tokenBudget) \u2014 a skill narrows and can never widen. The decision reports it as effectiveBudget, and policyDigest always digests the DOCUMENT's policy so an auditor can join a decision back to the document that authorised it. DIGESTS: policyDigest is sha256 over the RFC 8785 (JCS) canonicalisation of the policy, so gates in different languages agree byte-for-byte. Conformance is demonstrated by passing the published test vectors (tests/enforcement-vectors.json), which pin allow, reasonCode and policyDigest.",
|
|
6
|
+
"$defs": {
|
|
7
|
+
"request": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"required": [
|
|
10
|
+
"model",
|
|
11
|
+
"useCase",
|
|
12
|
+
"tokens"
|
|
13
|
+
],
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"properties": {
|
|
16
|
+
"model": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"minLength": 1,
|
|
19
|
+
"description": "Model identifier the caller wants to invoke."
|
|
20
|
+
},
|
|
21
|
+
"useCase": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"minLength": 1,
|
|
24
|
+
"description": "Declared use case of the call."
|
|
25
|
+
},
|
|
26
|
+
"purpose": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"minLength": 1,
|
|
29
|
+
"description": "Free-text purpose statement. Required by policies with purposeLimitation (the default)."
|
|
30
|
+
},
|
|
31
|
+
"tokens": {
|
|
32
|
+
"type": "integer",
|
|
33
|
+
"minimum": 0,
|
|
34
|
+
"description": "Requested token budget for the call. An integer in the JSON sense (200000.0 is 200000; true is not 1)."
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"decision": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"required": [
|
|
41
|
+
"allow",
|
|
42
|
+
"reasonCode",
|
|
43
|
+
"reason",
|
|
44
|
+
"policyDigest",
|
|
45
|
+
"request"
|
|
46
|
+
],
|
|
47
|
+
"additionalProperties": false,
|
|
48
|
+
"properties": {
|
|
49
|
+
"allow": {
|
|
50
|
+
"type": "boolean"
|
|
51
|
+
},
|
|
52
|
+
"reasonCode": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"enum": [
|
|
55
|
+
"ALLOWED",
|
|
56
|
+
"INVALID_POLICY",
|
|
57
|
+
"INVALID_REQUEST",
|
|
58
|
+
"MODEL_NOT_ALLOWED",
|
|
59
|
+
"USE_CASE_DENIED",
|
|
60
|
+
"USE_CASE_NOT_ALLOWED",
|
|
61
|
+
"PURPOSE_REQUIRED",
|
|
62
|
+
"TOKEN_BUDGET_EXCEEDED"
|
|
63
|
+
],
|
|
64
|
+
"description": "Machine-readable outcome. Deny codes follow the normative check order; the first failing check wins."
|
|
65
|
+
},
|
|
66
|
+
"reason": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"minLength": 1,
|
|
69
|
+
"description": "Human-readable explanation."
|
|
70
|
+
},
|
|
71
|
+
"policyDigest": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"pattern": "^sha256:[a-f0-9]{64}$",
|
|
74
|
+
"description": "sha256 over the RFC 8785 (JCS) canonicalisation of the DOCUMENT's agentPolicy \u2014 the audit anchor binding decision to policy. Never a synthesised or skill-narrowed policy."
|
|
75
|
+
},
|
|
76
|
+
"request": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"description": "Echo of the evaluated request. Deliberately unconstrained: on the INVALID_REQUEST path the request is malformed by definition, and the decision must still be able to carry it. The decision plus this echo is the audit record."
|
|
79
|
+
},
|
|
80
|
+
"effectiveBudget": {
|
|
81
|
+
"type": "integer",
|
|
82
|
+
"minimum": 0,
|
|
83
|
+
"description": "The token budget actually applied \u2014 min(skill, policy) when a skill narrows it. Present whenever the policy was well-formed."
|
|
84
|
+
},
|
|
85
|
+
"skillRef": {
|
|
86
|
+
"type": "string",
|
|
87
|
+
"minLength": 1,
|
|
88
|
+
"description": "Name of the skill binding the call was made through, when there was one. With effectiveBudget this makes the narrowing auditable without mutating the policy."
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"allOf": [
|
|
92
|
+
{
|
|
93
|
+
"if": {
|
|
94
|
+
"properties": {
|
|
95
|
+
"reasonCode": {
|
|
96
|
+
"const": "ALLOWED"
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"required": [
|
|
100
|
+
"reasonCode"
|
|
101
|
+
]
|
|
102
|
+
},
|
|
103
|
+
"then": {
|
|
104
|
+
"properties": {
|
|
105
|
+
"allow": {
|
|
106
|
+
"const": true
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"else": {
|
|
111
|
+
"properties": {
|
|
112
|
+
"allow": {
|
|
113
|
+
"const": false
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://agenticstiger.github.io/flux/schema/flux-manifest-0.5.0.json",
|
|
4
|
+
"title": "FLUX Bundle Manifest",
|
|
5
|
+
"description": "RFC-09: the self-describing manifest inside a FLUX evidence bundle. The archive is deterministic (sorted entries, zeroed timestamps), so identical bundle directories produce byte-identical archives. Signing is a detached signature over the manifest file bytes (flux-manifest.json), transport-agnostic: ssh-keygen -Y, cosign, or openssl all work. A verifier recomputes every file digest and the Merkle root offline, then replays the reference validator.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"manifestVersion",
|
|
9
|
+
"schemaId",
|
|
10
|
+
"fluxVersions",
|
|
11
|
+
"files",
|
|
12
|
+
"merkleRoot",
|
|
13
|
+
"attestation"
|
|
14
|
+
],
|
|
15
|
+
"additionalProperties": false,
|
|
16
|
+
"properties": {
|
|
17
|
+
"manifestVersion": {
|
|
18
|
+
"const": 1
|
|
19
|
+
},
|
|
20
|
+
"generator": {
|
|
21
|
+
"type": "string"
|
|
22
|
+
},
|
|
23
|
+
"schemaId": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"pattern": "^https://",
|
|
26
|
+
"description": "The $id of the FLUX schema the bundle was validated against."
|
|
27
|
+
},
|
|
28
|
+
"fluxVersions": {
|
|
29
|
+
"type": "array",
|
|
30
|
+
"minItems": 1,
|
|
31
|
+
"uniqueItems": true,
|
|
32
|
+
"items": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
|
35
|
+
},
|
|
36
|
+
"description": "Every fluxVersion pinned by documents in the bundle."
|
|
37
|
+
},
|
|
38
|
+
"files": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"minProperties": 1,
|
|
41
|
+
"additionalProperties": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"pattern": "^sha256:[a-f0-9]{64}$"
|
|
44
|
+
},
|
|
45
|
+
"description": "Relative path -> sha256 of every file in the bundle (the manifest itself excluded)."
|
|
46
|
+
},
|
|
47
|
+
"merkleRoot": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"pattern": "^sha256:[a-f0-9]{64}$",
|
|
50
|
+
"description": "Root over the sorted file entries. Leaves are sha256('leaf:' + path + ':' + fileDigestHex) in path order; parents are sha256('node:' + leftHex + rightHex); an odd node is promoted unchanged."
|
|
51
|
+
},
|
|
52
|
+
"attestation": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"required": [
|
|
55
|
+
"profile",
|
|
56
|
+
"validator"
|
|
57
|
+
],
|
|
58
|
+
"additionalProperties": false,
|
|
59
|
+
"properties": {
|
|
60
|
+
"profile": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"enum": [
|
|
63
|
+
"core",
|
|
64
|
+
"enterprise"
|
|
65
|
+
],
|
|
66
|
+
"description": "Highest conformance profile the offline toolchain can attest. 'enterprise' requires a green validator plus a Playback scorecard in the bundle; 'runtime' (Level 3) requires a live engine and is never claimable by an offline bundler."
|
|
67
|
+
},
|
|
68
|
+
"validator": {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"required": [
|
|
71
|
+
"status"
|
|
72
|
+
],
|
|
73
|
+
"additionalProperties": false,
|
|
74
|
+
"properties": {
|
|
75
|
+
"status": {
|
|
76
|
+
"const": "green"
|
|
77
|
+
},
|
|
78
|
+
"documents": {
|
|
79
|
+
"type": "integer",
|
|
80
|
+
"minimum": 1
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"scorecards": {
|
|
85
|
+
"type": "array",
|
|
86
|
+
"minItems": 1,
|
|
87
|
+
"items": {
|
|
88
|
+
"type": "object",
|
|
89
|
+
"required": [
|
|
90
|
+
"playbackId",
|
|
91
|
+
"grade",
|
|
92
|
+
"credibility"
|
|
93
|
+
],
|
|
94
|
+
"additionalProperties": false,
|
|
95
|
+
"properties": {
|
|
96
|
+
"playbackId": {
|
|
97
|
+
"type": "string"
|
|
98
|
+
},
|
|
99
|
+
"grade": {
|
|
100
|
+
"type": "string",
|
|
101
|
+
"enum": [
|
|
102
|
+
"A",
|
|
103
|
+
"B",
|
|
104
|
+
"C",
|
|
105
|
+
"D",
|
|
106
|
+
"E",
|
|
107
|
+
"F"
|
|
108
|
+
]
|
|
109
|
+
},
|
|
110
|
+
"credibility": {
|
|
111
|
+
"type": "number",
|
|
112
|
+
"minimum": 0,
|
|
113
|
+
"maximum": 1
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"seam": {
|
|
119
|
+
"type": "array",
|
|
120
|
+
"minItems": 1,
|
|
121
|
+
"items": {
|
|
122
|
+
"type": "object",
|
|
123
|
+
"required": [
|
|
124
|
+
"simulationId",
|
|
125
|
+
"productRef",
|
|
126
|
+
"exposeId",
|
|
127
|
+
"fluidVersion"
|
|
128
|
+
],
|
|
129
|
+
"additionalProperties": false,
|
|
130
|
+
"properties": {
|
|
131
|
+
"simulationId": {
|
|
132
|
+
"type": "string"
|
|
133
|
+
},
|
|
134
|
+
"productRef": {
|
|
135
|
+
"type": "string"
|
|
136
|
+
},
|
|
137
|
+
"exposeId": {
|
|
138
|
+
"type": "string"
|
|
139
|
+
},
|
|
140
|
+
"fluidVersion": {
|
|
141
|
+
"type": "string"
|
|
142
|
+
},
|
|
143
|
+
"contractDigest": {
|
|
144
|
+
"type": "string",
|
|
145
|
+
"pattern": "^sha256:[a-f0-9]{64}$"
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|