capsule-engine 0.1.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.
- capsule_engine/__init__.py +17 -0
- capsule_engine/bundle_viewer/__init__.py +21 -0
- capsule_engine/bundle_viewer/static/offline_shell.html +1509 -0
- capsule_engine/bundle_viewer/viewer.py +57 -0
- capsule_engine/cli/__init__.py +5 -0
- capsule_engine/cli/bundle_cmd.py +269 -0
- capsule_engine/cli/console_cmd.py +70 -0
- capsule_engine/cli/constraints_cmd.py +74 -0
- capsule_engine/cli/format.py +173 -0
- capsule_engine/cli/guard_cmds.py +361 -0
- capsule_engine/cli/init_cmds.py +169 -0
- capsule_engine/cli/main.py +120 -0
- capsule_engine/cli/packs_cmds.py +96 -0
- capsule_engine/cli/telemetry_cmd.py +80 -0
- capsule_engine/cli/tenant_cmds.py +199 -0
- capsule_engine/cli/verify_cmd.py +110 -0
- capsule_engine/compiler/__init__.py +13 -0
- capsule_engine/compiler/effect_model.py +79 -0
- capsule_engine/compiler/vocabulary.py +253 -0
- capsule_engine/console/__init__.py +10 -0
- capsule_engine/console/api.py +251 -0
- capsule_engine/console/components.css +406 -0
- capsule_engine/console/console.css +130 -0
- capsule_engine/console/console.html +51 -0
- capsule_engine/console/console.js +330 -0
- capsule_engine/console/gallery.html +274 -0
- capsule_engine/console/server.py +137 -0
- capsule_engine/console/tokens.css +68 -0
- capsule_engine/conversation/__init__.py +88 -0
- capsule_engine/conversation/capsules.py +268 -0
- capsule_engine/conversation/exchange.py +279 -0
- capsule_engine/conversation/merkle.py +80 -0
- capsule_engine/conversation/session.py +101 -0
- capsule_engine/envcompat.py +15 -0
- capsule_engine/events/__init__.py +14 -0
- capsule_engine/events/capsule.py +133 -0
- capsule_engine/examples/__init__.py +9 -0
- capsule_engine/examples/data/tau2_airline/LICENSE-tau2-bench +21 -0
- capsule_engine/examples/data/tau2_airline/PROVENANCE.json +70 -0
- capsule_engine/examples/data/tau2_airline/hand_labels.json +893 -0
- capsule_engine/examples/data/tau2_airline/pilot1_live_gemini-2-5-flash_airline_shift1.jsonl +190 -0
- capsule_engine/examples/data/tau2_airline/tau2_committed_claude-3-7-sonnet_airline_trial0.jsonl +556 -0
- capsule_engine/examples/data/tau2_airline/tau2_committed_gpt-4-1-mini_airline_trial0.jsonl +480 -0
- capsule_engine/examples/data/tau2_airline/tau2_committed_gpt-4-1_airline_trial0.jsonl +496 -0
- capsule_engine/examples/data/tau2_airline/tau2_committed_o4-mini_airline_trial0.jsonl +392 -0
- capsule_engine/examples/data/tau2_airline/tau2_conversations_claude-3-7-sonnet_airline_4trials.jsonl +200 -0
- capsule_engine/examples/tau2_airline_reference.py +460 -0
- capsule_engine/examples/tau2_conversation_exchange.py +92 -0
- capsule_engine/folds/__init__.py +104 -0
- capsule_engine/folds/account_core.py +89 -0
- capsule_engine/folds/approval_latency.py +86 -0
- capsule_engine/folds/catalog.py +79 -0
- capsule_engine/folds/catalog_defs/actions.count_by_developer.yaml +10 -0
- capsule_engine/folds/catalog_defs/actions.executed_count.yaml +16 -0
- capsule_engine/folds/catalog_defs/actions.last_decision.yaml +12 -0
- capsule_engine/folds/catalog_defs/counterparty.seen_before.v2.yaml +34 -0
- capsule_engine/folds/catalog_defs/counterparty.seen_before.v3.yaml +38 -0
- capsule_engine/folds/catalog_defs/counterparty.seen_before.yaml +28 -0
- capsule_engine/folds/catalog_defs/disclosure.class_seen.yaml +29 -0
- capsule_engine/folds/catalog_defs/disclosure.statement_seen.yaml +32 -0
- capsule_engine/folds/catalog_defs/hold.active_exposure.yaml +44 -0
- capsule_engine/folds/catalog_defs/spend.weekly.v2.yaml +38 -0
- capsule_engine/folds/catalog_defs/spend.weekly.v3.1.yaml +71 -0
- capsule_engine/folds/catalog_defs/spend.weekly.v3.yaml +76 -0
- capsule_engine/folds/catalog_defs/spend.weekly.yaml +33 -0
- capsule_engine/folds/counterparty_signals.py +179 -0
- capsule_engine/folds/definition.py +421 -0
- capsule_engine/folds/duration.py +16 -0
- capsule_engine/folds/engine.py +436 -0
- capsule_engine/folds/errors.py +44 -0
- capsule_engine/folds/loader.py +24 -0
- capsule_engine/folds/ordering.py +143 -0
- capsule_engine/folds/paths.py +25 -0
- capsule_engine/folds/record_type_coverage.py +58 -0
- capsule_engine/folds/reducers.py +75 -0
- capsule_engine/folds/retention_continuity.py +117 -0
- capsule_engine/folds/set_membership.py +59 -0
- capsule_engine/folds/taxonomy.py +231 -0
- capsule_engine/guards/__init__.py +59 -0
- capsule_engine/guards/action.py +446 -0
- capsule_engine/guards/action_taxonomy.json +27 -0
- capsule_engine/guards/action_taxonomy_v2.json +27 -0
- capsule_engine/guards/action_taxonomy_v3.json +27 -0
- capsule_engine/guards/action_taxonomy_v4.json +27 -0
- capsule_engine/guards/action_taxonomy_v5.json +27 -0
- capsule_engine/guards/capsule.py +377 -0
- capsule_engine/guards/checks/__init__.py +268 -0
- capsule_engine/guards/checks/action_class_gate.py +199 -0
- capsule_engine/guards/checks/base.py +33 -0
- capsule_engine/guards/checks/caps.py +517 -0
- capsule_engine/guards/checks/channel_change.py +64 -0
- capsule_engine/guards/checks/commitment.py +41 -0
- capsule_engine/guards/checks/counterparty_identity_change.py +95 -0
- capsule_engine/guards/checks/counterparty_list.py +176 -0
- capsule_engine/guards/checks/counterparty_seen_before.py +160 -0
- capsule_engine/guards/checks/credential_pattern.py +73 -0
- capsule_engine/guards/checks/dedupe.py +259 -0
- capsule_engine/guards/checks/destination_rail.py +120 -0
- capsule_engine/guards/checks/field_change_count.py +100 -0
- capsule_engine/guards/checks/offer_expiry.py +134 -0
- capsule_engine/guards/checks/plan_containment.py +180 -0
- capsule_engine/guards/checks/price_floor.py +211 -0
- capsule_engine/guards/checks/promise_never.py +236 -0
- capsule_engine/guards/checks/promise_requires_approval.py +214 -0
- capsule_engine/guards/checks/recipient_role.py +117 -0
- capsule_engine/guards/checks/recurring_charge.py +49 -0
- capsule_engine/guards/checks/refundability.py +46 -0
- capsule_engine/guards/checks/release_on_acceptance.py +286 -0
- capsule_engine/guards/checks/required_disclosure.py +233 -0
- capsule_engine/guards/checks/single_commitment.py +221 -0
- capsule_engine/guards/checks/task_authority.py +182 -0
- capsule_engine/guards/checks/upfront_amount.py +71 -0
- capsule_engine/guards/checks/verify_before_dispatch.py +75 -0
- capsule_engine/guards/classes.py +393 -0
- capsule_engine/guards/engine.py +926 -0
- capsule_engine/guards/history_state.py +127 -0
- capsule_engine/guards/plan.py +237 -0
- capsule_engine/guards/revocation.py +120 -0
- capsule_engine/guards/signing.py +60 -0
- capsule_engine/guards/statements.py +62 -0
- capsule_engine/guards/tool_call.py +83 -0
- capsule_engine/guards/wickets/__init__.py +23 -0
- capsule_engine/guards/wickets/catalog.py +75 -0
- capsule_engine/guards/wickets/catalog_defs/action_class_gate.v1.1.yaml +38 -0
- capsule_engine/guards/wickets/catalog_defs/action_class_gate.v1.2.yaml +52 -0
- capsule_engine/guards/wickets/catalog_defs/action_class_gate.yaml +34 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v2.yaml +30 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v3.yaml +49 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v4.yaml +45 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v5.1.yaml +53 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v5.yaml +43 -0
- capsule_engine/guards/wickets/catalog_defs/caps.yaml +17 -0
- capsule_engine/guards/wickets/catalog_defs/caps_holds.yaml +18 -0
- capsule_engine/guards/wickets/catalog_defs/channel_change.v1.1.yaml +18 -0
- capsule_engine/guards/wickets/catalog_defs/channel_change.yaml +10 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_identity_change.yaml +9 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_list.yaml +20 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v2.yaml +15 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v3.yaml +23 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v4.yaml +26 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/credential_pattern.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/dedupe.yaml +19 -0
- capsule_engine/guards/wickets/catalog_defs/destination_rail.seller.v1.1.yaml +32 -0
- capsule_engine/guards/wickets/catalog_defs/destination_rail.seller.yaml +10 -0
- capsule_engine/guards/wickets/catalog_defs/destination_rail.yaml +9 -0
- capsule_engine/guards/wickets/catalog_defs/hold_reconcile.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/material_fields_changed.v1.1.yaml +22 -0
- capsule_engine/guards/wickets/catalog_defs/material_fields_changed.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/offer_expiry.v1.1.yaml +31 -0
- capsule_engine/guards/wickets/catalog_defs/offer_expiry.yaml +24 -0
- capsule_engine/guards/wickets/catalog_defs/offer_fields_changed.v1.1.yaml +22 -0
- capsule_engine/guards/wickets/catalog_defs/offer_fields_changed.yaml +10 -0
- capsule_engine/guards/wickets/catalog_defs/price_floor.yaml +32 -0
- capsule_engine/guards/wickets/catalog_defs/promise_never.v1.1.yaml +64 -0
- capsule_engine/guards/wickets/catalog_defs/promise_never.yaml +16 -0
- capsule_engine/guards/wickets/catalog_defs/promise_requires_approval.v1.1.yaml +26 -0
- capsule_engine/guards/wickets/catalog_defs/promise_requires_approval.yaml +21 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.v1.1.yaml +23 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.v1.2.yaml +24 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.yaml +10 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.v1.1.yaml +26 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.yaml +11 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_seen_before.yaml +16 -0
- capsule_engine/guards/wickets/catalog_defs/recurring_charge.yaml +9 -0
- capsule_engine/guards/wickets/catalog_defs/refundability.v1.1.yaml +15 -0
- capsule_engine/guards/wickets/catalog_defs/refundability.yaml +7 -0
- capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.v1.1.yaml +44 -0
- capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.v1.2.yaml +63 -0
- capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.yaml +36 -0
- capsule_engine/guards/wickets/catalog_defs/required_disclosure.v1.1.yaml +36 -0
- capsule_engine/guards/wickets/catalog_defs/required_disclosure.v1.2.yaml +42 -0
- capsule_engine/guards/wickets/catalog_defs/required_disclosure.yaml +25 -0
- capsule_engine/guards/wickets/catalog_defs/single_commitment.seller.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/task_authority.v1.1.yaml +20 -0
- capsule_engine/guards/wickets/catalog_defs/task_authority.yaml +8 -0
- capsule_engine/guards/wickets/catalog_defs/upfront_amount.v1.1.yaml +19 -0
- capsule_engine/guards/wickets/catalog_defs/upfront_amount.yaml +11 -0
- capsule_engine/guards/wickets/catalog_defs/verify_before_dispatch.yaml +9 -0
- capsule_engine/guards/wickets/definition.py +164 -0
- capsule_engine/guards/wickets/errors.py +18 -0
- capsule_engine/guards/wickets/loader.py +26 -0
- capsule_engine/guards/wickets/retired.py +62 -0
- capsule_engine/holds/__init__.py +35 -0
- capsule_engine/holds/capsules.py +266 -0
- capsule_engine/holds/engine.py +476 -0
- capsule_engine/holds/errors.py +29 -0
- capsule_engine/holds/policy.py +62 -0
- capsule_engine/holds/scope.py +47 -0
- capsule_engine/mcp/__init__.py +16 -0
- capsule_engine/mcp/config.py +117 -0
- capsule_engine/mcp/server.py +338 -0
- capsule_engine/mcp/tools.py +424 -0
- capsule_engine/packaging.py +60 -0
- capsule_engine/packs/__init__.py +45 -0
- capsule_engine/packs/backfill_coverage.py +238 -0
- capsule_engine/packs/catalog/airline-engagement/AI-BOOTSTRAP.md +53 -0
- capsule_engine/packs/catalog/airline-engagement/folds/session_close.yaml +8 -0
- capsule_engine/packs/catalog/airline-engagement/pack.yaml +146 -0
- capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/good_execution.json +36 -0
- capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/oversight_declared.json +36 -0
- capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/tampered_execution.json +36 -0
- capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/trusted_issuer.jwk.json +5 -0
- capsule_engine/packs/catalog/eu-ai-act/obligations-pack-note.md +68 -0
- capsule_engine/packs/catalog/eu-ai-act/obligations-register.yaml +761 -0
- capsule_engine/packs/catalog/eu-ai-act/pack.yaml +88 -0
- capsule_engine/packs/catalog/eu-ai-act/register.yaml +96 -0
- capsule_engine/packs/catalog/eu-ai-act-deterministic/fixtures/synthetic_internal_assist_sample.jsonl +6 -0
- capsule_engine/packs/catalog/eu-ai-act-deterministic/pack.yaml +580 -0
- capsule_engine/packs/catalog/everyday/AI-BOOTSTRAP.md +194 -0
- capsule_engine/packs/catalog/everyday/fixtures/mini_ledger.jsonl +45 -0
- capsule_engine/packs/catalog/everyday/pack.yaml +480 -0
- capsule_engine/packs/catalog/payments-safety/AI-BOOTSTRAP.md +83 -0
- capsule_engine/packs/catalog/payments-safety/fixtures/mini_ledger.jsonl +8 -0
- capsule_engine/packs/catalog/payments-safety/folds/spend_weekly.yaml +29 -0
- capsule_engine/packs/catalog/payments-safety/pack.yaml +112 -0
- capsule_engine/packs/catalog/seller/fixtures/mini_ledger.jsonl +83 -0
- capsule_engine/packs/catalog/seller/pack.yaml +503 -0
- capsule_engine/packs/catalog/standard-vendor/AI-BOOTSTRAP.md +70 -0
- capsule_engine/packs/catalog/standard-vendor/folds/session_close.yaml +8 -0
- capsule_engine/packs/catalog/standard-vendor/pack.yaml +466 -0
- capsule_engine/packs/contract_diff.py +809 -0
- capsule_engine/packs/contract_validate.py +185 -0
- capsule_engine/packs/corpus_verify.py +126 -0
- capsule_engine/packs/enforce.py +81 -0
- capsule_engine/packs/errors.py +159 -0
- capsule_engine/packs/eu_ai_act_trace_field_map.py +46 -0
- capsule_engine/packs/install.py +266 -0
- capsule_engine/packs/loader.py +1462 -0
- capsule_engine/packs/measurability_report.py +280 -0
- capsule_engine/packs/obligation_results.py +72 -0
- capsule_engine/packs/pins.py +92 -0
- capsule_engine/packs/schema.py +1053 -0
- capsule_engine/packs/thresholds.py +146 -0
- capsule_engine/packs/trace_attainment.py +188 -0
- capsule_engine/packs/trace_reader.py +104 -0
- capsule_engine/policy/__init__.py +37 -0
- capsule_engine/policy/activation.py +87 -0
- capsule_engine/policy/catalog_defs/default.yaml +23 -0
- capsule_engine/policy/catalog_defs/holds.yaml +27 -0
- capsule_engine/policy/errors.py +46 -0
- capsule_engine/policy/limits.py +287 -0
- capsule_engine/policy/loader.py +26 -0
- capsule_engine/policy/manifest.py +289 -0
- capsule_engine/policy/profile.py +281 -0
- capsule_engine/policy/resolve.py +224 -0
- capsule_engine/register/__init__.py +39 -0
- capsule_engine/register/compiler.py +219 -0
- capsule_engine/register/errors.py +40 -0
- capsule_engine/register/loader.py +247 -0
- capsule_engine/register/schema.py +129 -0
- capsule_engine/registry/__init__.py +19 -0
- capsule_engine/registry/conventions.json +162 -0
- capsule_engine/registry/conventions.py +188 -0
- capsule_engine/registry/cpb_registry.json +78 -0
- capsule_engine/report/__init__.py +62 -0
- capsule_engine/report/build.py +267 -0
- capsule_engine/report/coverage.py +777 -0
- capsule_engine/report/errors.py +35 -0
- capsule_engine/report/live_check.py +126 -0
- capsule_engine/report/live_history.py +420 -0
- capsule_engine/report/model.py +95 -0
- capsule_engine/report/render.py +467 -0
- capsule_engine/report/replay.py +1677 -0
- capsule_engine/report/result.py +504 -0
- capsule_engine/report/result_from_folds.py +222 -0
- capsule_engine/report/result_from_layers.py +670 -0
- capsule_engine/report/sale_bundle.py +864 -0
- capsule_engine/report/static/verify.js +451 -0
- capsule_engine/schemas/evidence-contract-v0.json +383 -0
- capsule_engine/schemas/vendor/epistemic-types.json +21 -0
- capsule_engine/schemas/vendor/evidence-result-v0.json +463 -0
- capsule_engine/telemetry/__init__.py +9 -0
- capsule_engine/telemetry/consent.py +39 -0
- capsule_engine/telemetry/events.py +93 -0
- capsule_engine/telemetry/fixtures/synthetic_events.jsonl +45 -0
- capsule_engine/telemetry/funnel.py +147 -0
- capsule_engine/telemetry/record.py +106 -0
- capsule_engine/telemetry/sink.py +61 -0
- capsule_engine/telemetry/state.py +60 -0
- capsule_engine/tenants.py +327 -0
- capsule_engine-0.1.0.dist-info/METADATA +47 -0
- capsule_engine-0.1.0.dist-info/RECORD +288 -0
- capsule_engine-0.1.0.dist-info/WHEEL +5 -0
- capsule_engine-0.1.0.dist-info/entry_points.txt +2 -0
- capsule_engine-0.1.0.dist-info/licenses/LICENSE +202 -0
- capsule_engine-0.1.0.dist-info/licenses/capsule_engine/examples/data/tau2_airline/LICENSE-tau2-bench +21 -0
- capsule_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Which ``caps`` limit is in force for a decision, and where it came from.
|
|
3
|
+
|
|
4
|
+
A user's limit lives in a policy profile (``profile.py``) that an activation
|
|
5
|
+
record binds (``activation.py``): the record carries the profile's values and
|
|
6
|
+
digest and is appended, signed, to the ledger. ``ResolvedManifest`` lays the
|
|
7
|
+
profile over the wicket's defaults as soon as it is installed; this module
|
|
8
|
+
adds the one rule that depends on *when* a value was activated:
|
|
9
|
+
|
|
10
|
+
* a value at or below the limit in force when it was activated takes effect
|
|
11
|
+
at that activation's timestamp;
|
|
12
|
+
* a value above it takes effect ``COOLING_OFF`` (12 hours) after that
|
|
13
|
+
timestamp, and until then the earlier limit stays in force;
|
|
14
|
+
* each activation supersedes any raise an earlier one left pending.
|
|
15
|
+
|
|
16
|
+
"Earlier limit" starts at the wicket's default, so a first profile that sets
|
|
17
|
+
a limit above the pack's default waits too, and so does removing a profile
|
|
18
|
+
whose limit was below it. Lower is tighter and higher is looser, as
|
|
19
|
+
``packs/contract_diff.py`` classifies a profile limit; that diff reports a
|
|
20
|
+
class set on only one side as ``changed`` because it does not read the pack
|
|
21
|
+
default, and here the default is known, so every change has a direction.
|
|
22
|
+
|
|
23
|
+
Which records count. The history is every activation in the ledger, in
|
|
24
|
+
append order, and it is read only when a profile is involved -- the install
|
|
25
|
+
pins one, or some activation carries one; otherwise every limit is the
|
|
26
|
+
wicket's default. When it is read:
|
|
27
|
+
|
|
28
|
+
* every record must verify under the engine's own signer
|
|
29
|
+
(``events.event_signature_valid``), so a record appended by anything that
|
|
30
|
+
does not hold the node's key is refused, not ignored;
|
|
31
|
+
* timestamps must not decrease in append order, so a record cannot be
|
|
32
|
+
backdated ahead of one already appended;
|
|
33
|
+
* the latest record must cite the installed manifest and the profile digest
|
|
34
|
+
that manifest pins.
|
|
35
|
+
|
|
36
|
+
A class a record's profile does not set reads the *installed* wicket's
|
|
37
|
+
default, including for records made under an earlier wicket. A pack upgrade
|
|
38
|
+
that changes a default is therefore not itself put through the cooling-off;
|
|
39
|
+
a value the operator set is, across any upgrade.
|
|
40
|
+
|
|
41
|
+
``CapsLimits.in_force`` is asked at two times -- the action's own timestamp
|
|
42
|
+
and the engine's clock -- and returns the lower of the two limits, so a
|
|
43
|
+
caller cannot reach a raise early by stamping its action later, or escape a
|
|
44
|
+
lowering by stamping it earlier. The value comes with its provenance --
|
|
45
|
+
``operator_profile`` with the digest of the profile that set it, or
|
|
46
|
+
``definition_default`` -- plus any activated raise still waiting out the
|
|
47
|
+
cooling-off. The caps check seals that provenance in its evidence
|
|
48
|
+
(``guards/checks/caps.py``'s ``limit_sources``).
|
|
49
|
+
"""
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
from collections.abc import Mapping, Sequence
|
|
53
|
+
from dataclasses import dataclass
|
|
54
|
+
from datetime import datetime, timedelta, timezone
|
|
55
|
+
from itertools import pairwise
|
|
56
|
+
|
|
57
|
+
from cll.ledger.api import LedgerAPI, ScanQuery
|
|
58
|
+
|
|
59
|
+
from ..events.capsule import event_signature_valid
|
|
60
|
+
from ..guards.checks.caps import (
|
|
61
|
+
LIMIT_SOURCE_DEFAULT,
|
|
62
|
+
LIMIT_SOURCE_PROFILE,
|
|
63
|
+
LimitSource,
|
|
64
|
+
PendingRaise,
|
|
65
|
+
cap_for,
|
|
66
|
+
)
|
|
67
|
+
from ..guards.classes import resolve
|
|
68
|
+
from ..guards.signing import Signer
|
|
69
|
+
from .activation import EVENT_MANIFEST_ACTIVATED
|
|
70
|
+
from .errors import (
|
|
71
|
+
ACTIVATION_OUT_OF_ORDER,
|
|
72
|
+
ACTIVATION_UNVERIFIED,
|
|
73
|
+
PROFILE_DIGEST_DRIFT,
|
|
74
|
+
PROFILE_UNBOUND,
|
|
75
|
+
PolicyManifestError,
|
|
76
|
+
)
|
|
77
|
+
from .profile import parse_profile
|
|
78
|
+
from .resolve import ResolvedManifest
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"COOLING_OFF",
|
|
82
|
+
"ActivationRecord",
|
|
83
|
+
"CapsLimits",
|
|
84
|
+
"LimitInForce",
|
|
85
|
+
"activation_records",
|
|
86
|
+
"caps_limits",
|
|
87
|
+
"read_caps_limits",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
COOLING_OFF = timedelta(hours=12)
|
|
91
|
+
|
|
92
|
+
# The caps config keys a profile may set (``profile.CONFIGURABLE["caps"]``).
|
|
93
|
+
_KEYS = ("caps_minor", "per_action_minor")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class ActivationRecord:
|
|
98
|
+
"""One ``policy_manifest_activated`` capsule, as appended."""
|
|
99
|
+
|
|
100
|
+
capsule: Mapping
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def timestamp(self) -> str:
|
|
104
|
+
return self.capsule["timestamp"]
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def detail(self) -> Mapping:
|
|
108
|
+
return self.capsule["asg_payload"]["detail"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class LimitInForce:
|
|
113
|
+
value_minor: int
|
|
114
|
+
source: LimitSource
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class _Step:
|
|
119
|
+
# Both ``None`` for the wicket's default before any activation.
|
|
120
|
+
activated_at: datetime | None
|
|
121
|
+
effective_at: datetime | None
|
|
122
|
+
value_minor: int
|
|
123
|
+
# ``None``: the wicket's default.
|
|
124
|
+
profile_digest: str | None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_ts(ts: str) -> datetime:
|
|
128
|
+
"""An ISO-8601 timestamp; one with no UTC offset is read as UTC, the
|
|
129
|
+
engine's convention for every timestamp it writes."""
|
|
130
|
+
dt = datetime.fromisoformat(ts[:-1] + "+00:00" if ts.endswith("Z") else ts)
|
|
131
|
+
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _format_ts(dt: datetime) -> str:
|
|
135
|
+
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _canonical(action_class: str) -> str:
|
|
139
|
+
ac = resolve(action_class)
|
|
140
|
+
return ac.name if ac is not None else action_class
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _source_name(step: _Step) -> str:
|
|
144
|
+
return LIMIT_SOURCE_DEFAULT if step.profile_digest is None else LIMIT_SOURCE_PROFILE
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _at(steps: tuple[_Step, ...], when: datetime) -> LimitInForce:
|
|
148
|
+
"""The limit in force at ``when``, and the raise pending then, if any:
|
|
149
|
+
one activated by ``when`` that takes effect after it."""
|
|
150
|
+
current = steps[0]
|
|
151
|
+
pending: _Step | None = None
|
|
152
|
+
for step in steps:
|
|
153
|
+
if step.effective_at is None or step.effective_at <= when:
|
|
154
|
+
current = step
|
|
155
|
+
elif step.activated_at is not None and step.activated_at <= when:
|
|
156
|
+
pending = step
|
|
157
|
+
source = LimitSource(limit_source=_source_name(current))
|
|
158
|
+
if current.profile_digest is not None:
|
|
159
|
+
source["profile_digest"] = current.profile_digest
|
|
160
|
+
if pending is not None and pending.effective_at is not None and pending.value_minor > current.value_minor:
|
|
161
|
+
raise_ = PendingRaise(
|
|
162
|
+
value_minor=pending.value_minor,
|
|
163
|
+
effective_at=_format_ts(pending.effective_at),
|
|
164
|
+
limit_source=_source_name(pending),
|
|
165
|
+
)
|
|
166
|
+
if pending.profile_digest is not None:
|
|
167
|
+
raise_["profile_digest"] = pending.profile_digest
|
|
168
|
+
source["pending_raise"] = raise_
|
|
169
|
+
return LimitInForce(value_minor=current.value_minor, source=source)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class CapsLimits:
|
|
174
|
+
"""Per caps key (``caps_minor`` / ``per_action_minor``), per canonical
|
|
175
|
+
action class, the limit's steps in activation order. ``effective_at`` is
|
|
176
|
+
non-decreasing along each tuple."""
|
|
177
|
+
|
|
178
|
+
steps: Mapping[str, Mapping[str, tuple[_Step, ...]]]
|
|
179
|
+
|
|
180
|
+
def in_force(self, key: str, action_class: str | None, *, at: str, now: str) -> LimitInForce | None:
|
|
181
|
+
"""The ``key`` limit for ``action_class``: the lower of the limits in
|
|
182
|
+
force at ``at`` (the action's timestamp) and ``now`` (the engine's
|
|
183
|
+
clock), the one at ``at`` on a tie. ``None`` when the installed wicket
|
|
184
|
+
sets no such limit for the class."""
|
|
185
|
+
by_class = self.steps.get(key, {})
|
|
186
|
+
if action_class is None or cap_for(dict.fromkeys(by_class, 0), action_class) is None:
|
|
187
|
+
return None
|
|
188
|
+
steps = by_class[_canonical(action_class)]
|
|
189
|
+
at_action = _at(steps, _parse_ts(at))
|
|
190
|
+
at_clock = _at(steps, _parse_ts(now))
|
|
191
|
+
return at_clock if at_clock.value_minor < at_action.value_minor else at_action
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def activation_records(ledger: LedgerAPI) -> list[ActivationRecord]:
|
|
195
|
+
"""Every ``policy_manifest_activated`` record in ``ledger``, in append order."""
|
|
196
|
+
return [
|
|
197
|
+
ActivationRecord(capsule=record.capsule)
|
|
198
|
+
for record in ledger.scan(ScanQuery(action_type="fyi"))
|
|
199
|
+
if (record.capsule.get("asg_payload") or {}).get("event") == EVENT_MANIFEST_ACTIVATED
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _profile_values(detail: Mapping, pack_names: set[str]) -> tuple[str | None, dict[str, dict[str, int]]]:
|
|
204
|
+
"""The profile digest an activation cites and the caps values it sets,
|
|
205
|
+
key -> class -> value. The values are re-digested: a record whose values
|
|
206
|
+
do not digest to the digest it cites is refused."""
|
|
207
|
+
profile = detail.get("profile")
|
|
208
|
+
if profile is None:
|
|
209
|
+
return None, {}
|
|
210
|
+
parsed = parse_profile(profile["values"])
|
|
211
|
+
if parsed.profile_digest() != profile["profile_digest"]:
|
|
212
|
+
raise PolicyManifestError(
|
|
213
|
+
PROFILE_DIGEST_DRIFT,
|
|
214
|
+
f"activation cites profile {profile['profile_digest']}, but its values digest to {parsed.profile_digest()}",
|
|
215
|
+
)
|
|
216
|
+
values: dict[str, dict[str, int]] = {}
|
|
217
|
+
for entry in parsed.packs:
|
|
218
|
+
if entry.pack in pack_names:
|
|
219
|
+
for key, by_class in entry.parameters.get("caps", {}).items():
|
|
220
|
+
values.setdefault(key, {}).update(by_class)
|
|
221
|
+
return profile["profile_digest"], values
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _check_history(resolved: ResolvedManifest, activations: Sequence[ActivationRecord], signer: Signer) -> None:
|
|
225
|
+
"""Refuse a history this module cannot read limits from (module docstring)."""
|
|
226
|
+
for record in activations:
|
|
227
|
+
if not event_signature_valid(record.capsule, signer):
|
|
228
|
+
raise PolicyManifestError(
|
|
229
|
+
ACTIVATION_UNVERIFIED,
|
|
230
|
+
f"activation {record.capsule.get('capsule_id')} does not verify under key {signer.key_id!r}",
|
|
231
|
+
)
|
|
232
|
+
for earlier, later in pairwise(activations):
|
|
233
|
+
if _parse_ts(later.timestamp) < _parse_ts(earlier.timestamp):
|
|
234
|
+
raise PolicyManifestError(
|
|
235
|
+
ACTIVATION_OUT_OF_ORDER,
|
|
236
|
+
f"activation {later.capsule.get('capsule_id')} at {later.timestamp} is appended after one at "
|
|
237
|
+
f"{earlier.timestamp}",
|
|
238
|
+
)
|
|
239
|
+
latest = activations[-1].detail if activations else None
|
|
240
|
+
cited = (latest.get("profile") or {}).get("profile_digest") if latest is not None else None
|
|
241
|
+
if latest is None or latest.get("manifest_digest") != resolved.manifest_digest or cited != resolved.manifest.profile_digest:
|
|
242
|
+
raise PolicyManifestError(
|
|
243
|
+
PROFILE_UNBOUND,
|
|
244
|
+
f"installed manifest {resolved.manifest_digest} (profile {resolved.manifest.profile_digest}) is not the "
|
|
245
|
+
f"one the latest activation binds ({latest.get('manifest_digest') if latest else 'none recorded'}, "
|
|
246
|
+
f"profile {cited}); a profile's limits apply only once an activation binds them",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def caps_limits(resolved: ResolvedManifest, activations: Sequence[ActivationRecord], signer: Signer) -> CapsLimits:
|
|
251
|
+
"""The caps limits of ``resolved`` over time, from the ledger's activation
|
|
252
|
+
records (``activation_records``), each verified under ``signer``. Raises
|
|
253
|
+
``PolicyManifestError`` when a profile is involved and the history cannot
|
|
254
|
+
be read (module docstring)."""
|
|
255
|
+
defaults = resolved.wicket_config("caps")
|
|
256
|
+
pack_names = {p.pack_id.rsplit("/", 1)[0] for p in resolved.manifest.packs}
|
|
257
|
+
involved = resolved.profile is not None or any(r.detail.get("profile") is not None for r in activations)
|
|
258
|
+
history: list[tuple[datetime, str | None, dict[str, dict[str, int]]]] = []
|
|
259
|
+
if involved:
|
|
260
|
+
_check_history(resolved, activations, signer)
|
|
261
|
+
history = [(_parse_ts(r.timestamp), *_profile_values(r.detail, pack_names)) for r in activations]
|
|
262
|
+
|
|
263
|
+
steps: dict[str, dict[str, tuple[_Step, ...]]] = {}
|
|
264
|
+
for key in _KEYS:
|
|
265
|
+
for action_class, default in (defaults.get(key) or {}).items():
|
|
266
|
+
schedule = [_Step(activated_at=None, effective_at=None, value_minor=default, profile_digest=None)]
|
|
267
|
+
for activated_at, digest, values in history:
|
|
268
|
+
set_by_profile = action_class in values.get(key, {})
|
|
269
|
+
value = values[key][action_class] if set_by_profile else default
|
|
270
|
+
# Drop any raise not yet in force: this activation supersedes it.
|
|
271
|
+
schedule = [s for s in schedule if s.effective_at is None or s.effective_at <= activated_at]
|
|
272
|
+
effective_at = activated_at if value <= schedule[-1].value_minor else activated_at + COOLING_OFF
|
|
273
|
+
schedule.append(
|
|
274
|
+
_Step(
|
|
275
|
+
activated_at=activated_at,
|
|
276
|
+
effective_at=effective_at,
|
|
277
|
+
value_minor=value,
|
|
278
|
+
profile_digest=digest if set_by_profile else None,
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
steps.setdefault(key, {})[_canonical(action_class)] = tuple(schedule)
|
|
282
|
+
return CapsLimits(steps=steps)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def read_caps_limits(resolved: ResolvedManifest, ledger: LedgerAPI, signer: Signer) -> CapsLimits:
|
|
286
|
+
"""``caps_limits`` over ``ledger``'s activation records as they stand now."""
|
|
287
|
+
return caps_limits(resolved, activation_records(ledger), signer)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""YAML front door for policy manifests: YAML text/file -> validated Manifest."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from .errors import MALFORMED_MANIFEST, PolicyManifestError
|
|
10
|
+
from .manifest import Manifest, parse_manifest
|
|
11
|
+
|
|
12
|
+
__all__ = ["load_manifest_text", "load_manifest_file"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_manifest_text(text: str) -> Manifest:
|
|
16
|
+
try:
|
|
17
|
+
data = yaml.safe_load(text)
|
|
18
|
+
except yaml.YAMLError as exc:
|
|
19
|
+
raise PolicyManifestError(MALFORMED_MANIFEST, f"invalid YAML: {exc}") from exc
|
|
20
|
+
if data is None:
|
|
21
|
+
raise PolicyManifestError(MALFORMED_MANIFEST, "empty manifest document")
|
|
22
|
+
return parse_manifest(data)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_manifest_file(path: str | Path) -> Manifest:
|
|
26
|
+
return load_manifest_text(Path(path).read_text())
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The policy manifest: one canonical, digestible file listing the active
|
|
3
|
+
fold and wicket (guard-constraint) definitions, *by digest*.
|
|
4
|
+
|
|
5
|
+
This is the declare-attest-verify piece (in-toto/SLSA pattern) for guard
|
|
6
|
+
policy: a manifest is a lockfile, not a copy. Each entry cites a fold_id or
|
|
7
|
+
wicket_id plus that definition's own already-real ``definition_digest()``
|
|
8
|
+
(``folds/definition.py`` / ``guards/wickets/definition.py``) -- never the
|
|
9
|
+
definition body itself, mirroring the same "definitions-by-digest, never
|
|
10
|
+
definitions-by-copy" rule fold *results* already follow when they cite
|
|
11
|
+
``fold_digest`` on a ledger record rather than embedding the fold. Because a
|
|
12
|
+
manifest is itself just declarative data, it gets a digest the exact same
|
|
13
|
+
way: SHA-256 over the JCS-canonical bytes of its own canonical form (see
|
|
14
|
+
``manifest_digest()``), via the same ``agent_action_capsule.canonical.
|
|
15
|
+
json_digest`` every other digest in this codebase uses.
|
|
16
|
+
|
|
17
|
+
Whether a manifest's pinned digests actually match what's sitting in the
|
|
18
|
+
fold/wicket catalogs *right now* is not this module's job -- see
|
|
19
|
+
``resolve.py`` for that cross-check. This module only parses and digests the
|
|
20
|
+
manifest as declared.
|
|
21
|
+
|
|
22
|
+
Design notes -- ``engine``:
|
|
23
|
+
|
|
24
|
+
Every entry also carries an ``engine`` string (``"fold/1"`` for folds,
|
|
25
|
+
``"wicket/1"`` for wickets today) identifying which evaluator resolves that
|
|
26
|
+
entry's semantics, separately from the digest identifying *which
|
|
27
|
+
definition*. Right now there is exactly one legal value per entry kind --
|
|
28
|
+
this repo's own built-in fold/wicket evaluator, kept as the shipped default
|
|
29
|
+
-- so the field looks decorative. It isn't: it participates in
|
|
30
|
+
``canonical_dict()`` (an entry that changes evaluation engine is a manifest
|
|
31
|
+
change, same as a changed digest) and ``resolve.py`` fails closed on any
|
|
32
|
+
engine value it doesn't recognize. The reasoning mirrors
|
|
33
|
+
``agent_action_capsule``'s own ``digest_alg`` field on capsule refs (see
|
|
34
|
+
``verify_composition.py``): ``"SHA-256"`` is the only legal value today, but
|
|
35
|
+
the field exists so a future algorithm can land as a new recognized value
|
|
36
|
+
rather than a wire-format break. Same trade here -- our evidentiary claims
|
|
37
|
+
("evaluated under manifest <digest>", byte-exact CI replay, mutant-tested
|
|
38
|
+
determinism) are cheap to defend precisely because the wicket/fold set is
|
|
39
|
+
closed and tiny; adopting a general declarative engine (OPA/Rego) would
|
|
40
|
+
trade that for a large language surface and an engine-version-pinning
|
|
41
|
+
burden, so it isn't the launch engine. If one is ever adopted, Cedar is the
|
|
42
|
+
better fit (declarative, formally analyzed, deterministic, no I/O by
|
|
43
|
+
design, Apache-2.0) -- but Cedar decides authorization over entities with
|
|
44
|
+
no concept of aggregates, so this repo would still own the fold half
|
|
45
|
+
regardless. This field is what keeps that door open as a new registry
|
|
46
|
+
entry later instead of a manifest-format break now: "any vendor's gate" is
|
|
47
|
+
the intended story, since the durable value this repo provides is the
|
|
48
|
+
recording/envelope layer, not the constraint language.
|
|
49
|
+
|
|
50
|
+
Design notes -- prior art considered, not adopted (2026-08-05, concept-only;
|
|
51
|
+
no external code was read or copied as part of building this module, and no
|
|
52
|
+
external project names belong in this public repo, so pointers to the
|
|
53
|
+
private inventory of that comparison live in this project's own non-public
|
|
54
|
+
tracking, not here):
|
|
55
|
+
|
|
56
|
+
- A content-hash + lifecycle-state registry pattern from prior internal
|
|
57
|
+
tooling was flagged as a closer analog to this manifest than what was
|
|
58
|
+
originally scoped for this task. This module was written clean-room
|
|
59
|
+
against public interfaces only; the pointer for whoever does that
|
|
60
|
+
comparison next is tracked privately.
|
|
61
|
+
- A three-way clearance taxonomy (deterministic / operator-approval /
|
|
62
|
+
evidence-first) from separate prior internal tooling is worth evaluating
|
|
63
|
+
against this manifest's wicket-clearance model -- not inherited wholesale
|
|
64
|
+
here.
|
|
65
|
+
"""
|
|
66
|
+
from __future__ import annotations
|
|
67
|
+
|
|
68
|
+
import re
|
|
69
|
+
from dataclasses import dataclass
|
|
70
|
+
from typing import Any
|
|
71
|
+
|
|
72
|
+
from agent_action_capsule.canonical import json_digest
|
|
73
|
+
from agent_action_capsule.contracts import is_hex64
|
|
74
|
+
|
|
75
|
+
from .errors import (
|
|
76
|
+
DUPLICATE_FOLD_REF,
|
|
77
|
+
DUPLICATE_PACK_REF,
|
|
78
|
+
DUPLICATE_WICKET_REF,
|
|
79
|
+
INVALID_DIGEST,
|
|
80
|
+
INVALID_MANIFEST_ID,
|
|
81
|
+
INVALID_PACK_MODE,
|
|
82
|
+
MALFORMED_MANIFEST,
|
|
83
|
+
PolicyManifestError,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
__all__ = ["MANIFEST_ID_RE", "PACK_MODES", "FoldRef", "WicketRef", "PackRef", "Manifest", "parse_manifest"]
|
|
87
|
+
|
|
88
|
+
# manifest_id: same "human name + semver" shape as fold_id/wicket_id.
|
|
89
|
+
MANIFEST_ID_RE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*/\d+\.\d+\.\d+$")
|
|
90
|
+
|
|
91
|
+
# A pack ref's lifecycle state (starter-packs plan: "observe mode: records,
|
|
92
|
+
# no enforcement" -> "enforce: thresholds accepted by a human, gate goes
|
|
93
|
+
# live"). Closed set, same discipline as ``engine`` below -- an unrecognized
|
|
94
|
+
# mode is a typo, never silently accepted as data.
|
|
95
|
+
PACK_MODES = frozenset({"observe", "enforce"})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class FoldRef:
|
|
100
|
+
fold_id: str
|
|
101
|
+
engine: str
|
|
102
|
+
digest: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class WicketRef:
|
|
107
|
+
wicket_id: str
|
|
108
|
+
engine: str
|
|
109
|
+
digest: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class PackRef:
|
|
114
|
+
"""One installed starter pack, cited by digest like a fold/wicket ref.
|
|
115
|
+
|
|
116
|
+
``digest`` is the pack's own ``definition_digest()`` (``packs/schema.py``
|
|
117
|
+
-- SHA-256 over the JCS-canonical bytes of its obligations/action-semantics/
|
|
118
|
+
constraint-and-fold-ref declaration), not a copy of the pack content --
|
|
119
|
+
same "definitions-by-digest, never definitions-by-copy" rule every other
|
|
120
|
+
entry in this manifest follows. ``mode`` is which lifecycle state this
|
|
121
|
+
pack is installed in *as of this manifest* -- flipping observe -> enforce
|
|
122
|
+
is itself a new manifest (a new ``PackRef.mode``), so "what was in force"
|
|
123
|
+
stays provable at every point in time, not just today.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
pack_id: str
|
|
127
|
+
engine: str
|
|
128
|
+
digest: str
|
|
129
|
+
mode: str
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class Manifest:
|
|
134
|
+
manifest_id: str
|
|
135
|
+
folds: tuple[FoldRef, ...] = ()
|
|
136
|
+
wickets: tuple[WicketRef, ...] = ()
|
|
137
|
+
packs: tuple[PackRef, ...] = ()
|
|
138
|
+
# The digest of the user's policy profile (``profile.py``), pinned like
|
|
139
|
+
# every fold and wicket: a changed limit is a changed manifest, while the
|
|
140
|
+
# wicket and pack it configures keep their digests.
|
|
141
|
+
profile_digest: str | None = None
|
|
142
|
+
|
|
143
|
+
def canonical_dict(self) -> dict:
|
|
144
|
+
"""The JCS-canonicalizable form of this manifest -- drives manifest_digest().
|
|
145
|
+
|
|
146
|
+
List order is preserved (not sorted) and is part of what gets
|
|
147
|
+
digested, same as a fold definition's ``reads`` list -- editing the
|
|
148
|
+
manifest file (even just reordering entries) is a manifest change."""
|
|
149
|
+
out: dict[str, Any] = {
|
|
150
|
+
"manifest_id": self.manifest_id,
|
|
151
|
+
"folds": [{"fold_id": f.fold_id, "engine": f.engine, "digest": f.digest} for f in self.folds],
|
|
152
|
+
"wickets": [{"wicket_id": w.wicket_id, "engine": w.engine, "digest": w.digest} for w in self.wickets],
|
|
153
|
+
}
|
|
154
|
+
# Omitted entirely (not an empty list) when no pack is installed, so
|
|
155
|
+
# a pre-pack manifest's canonical_dict -- and therefore its digest --
|
|
156
|
+
# is byte-identical to what it was before this field existed.
|
|
157
|
+
if self.packs:
|
|
158
|
+
out["packs"] = [
|
|
159
|
+
{"pack_id": p.pack_id, "engine": p.engine, "digest": p.digest, "mode": p.mode} for p in self.packs
|
|
160
|
+
]
|
|
161
|
+
# Omitted when no profile is pinned, by the same rule as ``packs``.
|
|
162
|
+
if self.profile_digest is not None:
|
|
163
|
+
out["profile_digest"] = self.profile_digest
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
def manifest_digest(self) -> str:
|
|
167
|
+
"""SHA-256 over the JCS bytes of the canonical manifest.
|
|
168
|
+
|
|
169
|
+
Every field in ``canonical_dict()`` is a string (ids and hex
|
|
170
|
+
digests), so unlike ``FoldDefinition.definition_digest()`` there is
|
|
171
|
+
no float/unsafe-integer failure mode to guard here -- ``json_digest``
|
|
172
|
+
cannot raise on an all-string payload."""
|
|
173
|
+
return json_digest(self.canonical_dict())
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _check_digest(value: Any, context: str) -> str:
|
|
177
|
+
if not isinstance(value, str) or not is_hex64(value):
|
|
178
|
+
raise PolicyManifestError(INVALID_DIGEST, f"{context} must be a 64-hex-char digest string, got {value!r}")
|
|
179
|
+
return value
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def parse_manifest(data: Any) -> Manifest:
|
|
183
|
+
"""Validate a plain dict (as loaded from YAML) into a ``Manifest``."""
|
|
184
|
+
if not isinstance(data, dict):
|
|
185
|
+
raise PolicyManifestError(MALFORMED_MANIFEST, "manifest must be a mapping")
|
|
186
|
+
|
|
187
|
+
manifest_id = data.get("manifest_id")
|
|
188
|
+
if not isinstance(manifest_id, str) or not MANIFEST_ID_RE.match(manifest_id):
|
|
189
|
+
raise PolicyManifestError(
|
|
190
|
+
INVALID_MANIFEST_ID,
|
|
191
|
+
f"manifest_id {manifest_id!r} must match '<namespace>[.<namespace>...]/<major>.<minor>.<patch>' "
|
|
192
|
+
"(e.g. 'default/1.0.0')",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
raw_folds = data.get("folds") or []
|
|
196
|
+
if not isinstance(raw_folds, list):
|
|
197
|
+
raise PolicyManifestError(MALFORMED_MANIFEST, "folds must be a list")
|
|
198
|
+
folds: list[FoldRef] = []
|
|
199
|
+
seen_fold_ids: set[str] = set()
|
|
200
|
+
for entry in raw_folds:
|
|
201
|
+
if not isinstance(entry, dict) or "fold_id" not in entry or "digest" not in entry or "engine" not in entry:
|
|
202
|
+
raise PolicyManifestError(
|
|
203
|
+
MALFORMED_MANIFEST, f"each folds entry needs 'fold_id', 'engine', and 'digest': {entry!r}"
|
|
204
|
+
)
|
|
205
|
+
fold_id = entry["fold_id"]
|
|
206
|
+
if not isinstance(fold_id, str) or not fold_id:
|
|
207
|
+
raise PolicyManifestError(MALFORMED_MANIFEST, f"folds[].fold_id must be a non-empty string: {entry!r}")
|
|
208
|
+
if fold_id in seen_fold_ids:
|
|
209
|
+
raise PolicyManifestError(DUPLICATE_FOLD_REF, f"fold_id {fold_id!r} declared more than once")
|
|
210
|
+
seen_fold_ids.add(fold_id)
|
|
211
|
+
engine = entry["engine"]
|
|
212
|
+
if not isinstance(engine, str) or not engine:
|
|
213
|
+
raise PolicyManifestError(
|
|
214
|
+
MALFORMED_MANIFEST, f"folds[{fold_id!r}].engine must be a non-empty string: {entry!r}"
|
|
215
|
+
)
|
|
216
|
+
digest = _check_digest(entry["digest"], f"folds[{fold_id!r}].digest")
|
|
217
|
+
folds.append(FoldRef(fold_id=fold_id, engine=engine, digest=digest))
|
|
218
|
+
|
|
219
|
+
raw_wickets = data.get("wickets") or []
|
|
220
|
+
if not isinstance(raw_wickets, list):
|
|
221
|
+
raise PolicyManifestError(MALFORMED_MANIFEST, "wickets must be a list")
|
|
222
|
+
wickets: list[WicketRef] = []
|
|
223
|
+
seen_wicket_ids: set[str] = set()
|
|
224
|
+
for entry in raw_wickets:
|
|
225
|
+
if not isinstance(entry, dict) or "wicket_id" not in entry or "digest" not in entry or "engine" not in entry:
|
|
226
|
+
raise PolicyManifestError(
|
|
227
|
+
MALFORMED_MANIFEST, f"each wickets entry needs 'wicket_id', 'engine', and 'digest': {entry!r}"
|
|
228
|
+
)
|
|
229
|
+
wicket_id = entry["wicket_id"]
|
|
230
|
+
if not isinstance(wicket_id, str) or not wicket_id:
|
|
231
|
+
raise PolicyManifestError(MALFORMED_MANIFEST, f"wickets[].wicket_id must be a non-empty string: {entry!r}")
|
|
232
|
+
if wicket_id in seen_wicket_ids:
|
|
233
|
+
raise PolicyManifestError(DUPLICATE_WICKET_REF, f"wicket_id {wicket_id!r} declared more than once")
|
|
234
|
+
seen_wicket_ids.add(wicket_id)
|
|
235
|
+
engine = entry["engine"]
|
|
236
|
+
if not isinstance(engine, str) or not engine:
|
|
237
|
+
raise PolicyManifestError(
|
|
238
|
+
MALFORMED_MANIFEST, f"wickets[{wicket_id!r}].engine must be a non-empty string: {entry!r}"
|
|
239
|
+
)
|
|
240
|
+
digest = _check_digest(entry["digest"], f"wickets[{wicket_id!r}].digest")
|
|
241
|
+
wickets.append(WicketRef(wicket_id=wicket_id, engine=engine, digest=digest))
|
|
242
|
+
|
|
243
|
+
raw_packs = data.get("packs") or []
|
|
244
|
+
if not isinstance(raw_packs, list):
|
|
245
|
+
raise PolicyManifestError(MALFORMED_MANIFEST, "packs must be a list")
|
|
246
|
+
packs: list[PackRef] = []
|
|
247
|
+
seen_pack_ids: set[str] = set()
|
|
248
|
+
for entry in raw_packs:
|
|
249
|
+
if (
|
|
250
|
+
not isinstance(entry, dict)
|
|
251
|
+
or "pack_id" not in entry
|
|
252
|
+
or "digest" not in entry
|
|
253
|
+
or "engine" not in entry
|
|
254
|
+
or "mode" not in entry
|
|
255
|
+
):
|
|
256
|
+
raise PolicyManifestError(
|
|
257
|
+
MALFORMED_MANIFEST,
|
|
258
|
+
f"each packs entry needs 'pack_id', 'engine', 'digest', and 'mode': {entry!r}",
|
|
259
|
+
)
|
|
260
|
+
pack_id = entry["pack_id"]
|
|
261
|
+
if not isinstance(pack_id, str) or not pack_id:
|
|
262
|
+
raise PolicyManifestError(MALFORMED_MANIFEST, f"packs[].pack_id must be a non-empty string: {entry!r}")
|
|
263
|
+
if pack_id in seen_pack_ids:
|
|
264
|
+
raise PolicyManifestError(DUPLICATE_PACK_REF, f"pack_id {pack_id!r} declared more than once")
|
|
265
|
+
seen_pack_ids.add(pack_id)
|
|
266
|
+
engine = entry["engine"]
|
|
267
|
+
if not isinstance(engine, str) or not engine:
|
|
268
|
+
raise PolicyManifestError(
|
|
269
|
+
MALFORMED_MANIFEST, f"packs[{pack_id!r}].engine must be a non-empty string: {entry!r}"
|
|
270
|
+
)
|
|
271
|
+
mode = entry["mode"]
|
|
272
|
+
if mode not in PACK_MODES:
|
|
273
|
+
raise PolicyManifestError(
|
|
274
|
+
INVALID_PACK_MODE, f"packs[{pack_id!r}].mode must be one of {sorted(PACK_MODES)}, got {mode!r}"
|
|
275
|
+
)
|
|
276
|
+
digest = _check_digest(entry["digest"], f"packs[{pack_id!r}].digest")
|
|
277
|
+
packs.append(PackRef(pack_id=pack_id, engine=engine, digest=digest, mode=mode))
|
|
278
|
+
|
|
279
|
+
profile_digest = data.get("profile_digest")
|
|
280
|
+
if profile_digest is not None:
|
|
281
|
+
_check_digest(profile_digest, "profile_digest")
|
|
282
|
+
|
|
283
|
+
return Manifest(
|
|
284
|
+
manifest_id=manifest_id,
|
|
285
|
+
folds=tuple(folds),
|
|
286
|
+
wickets=tuple(wickets),
|
|
287
|
+
packs=tuple(packs),
|
|
288
|
+
profile_digest=profile_digest,
|
|
289
|
+
)
|