interbolt 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.
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable
4
+ from typing import Protocol, runtime_checkable
5
+
6
+ from interbolt.models.core import Decision, Event, Finding
7
+
8
+
9
+ @runtime_checkable
10
+ class Reporter(Protocol):
11
+ """The reporting seam. `enforcement` emits through this, never a concrete type."""
12
+
13
+ def export(self, event: Event | Finding) -> None:
14
+ """Emit a decision event or audit finding.
15
+
16
+ Args:
17
+ event: The record to emit.
18
+
19
+ A reporter's `export` must never block or delay a decision; that is the
20
+ reporter author's responsibility, not a guarantee the engine provides.
21
+ """
22
+ ...
23
+
24
+
25
+ @runtime_checkable
26
+ class ApprovalResolver(Protocol):
27
+ """Resolves a `require_approval` decision to allow or deny.
28
+
29
+ Invoked synchronously at a sync call site and asynchronously (awaited) at an
30
+ async call site; a sync call site cannot use a resolver that only returns an
31
+ awaitable.
32
+ """
33
+
34
+ def __call__(self, decision: Decision) -> bool | Awaitable[bool]:
35
+ """Decide whether to allow the call that produced `decision`.
36
+
37
+ Args:
38
+ decision: The decision that requires approval.
39
+
40
+ Returns:
41
+ `True` to allow the call, `False` to deny it. May return an
42
+ awaitable when called from an async call site.
43
+ """
44
+ ...
45
+
46
+
47
+ def auto_deny(decision: Decision) -> bool:
48
+ """The default `ApprovalResolver`: deny every approval request.
49
+
50
+ Args:
51
+ decision: The decision that requires approval.
52
+
53
+ Returns:
54
+ Always `False`.
55
+ """
56
+ return False
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from interbolt.models.core import TrustLevel
4
+ from interbolt.policy.engine import CompiledSink, compile_policy
5
+ from interbolt.policy.schema import (
6
+ PolicyDocument,
7
+ load_policy_document,
8
+ validate_policy,
9
+ )
10
+
11
+
12
+ class Policy:
13
+ """A loaded, compiled policy: the validated document plus its compiled sinks.
14
+
15
+ Attributes:
16
+ document: The validated policy document.
17
+ compiled_sinks: Every sink's compiled, ready-to-evaluate rule list.
18
+ """
19
+
20
+ def __init__(
21
+ self, document: PolicyDocument, compiled_sinks: dict[str, CompiledSink]
22
+ ) -> None:
23
+ self.document = document
24
+ self.compiled_sinks = compiled_sinks
25
+
26
+ @property
27
+ def sources_table(self) -> dict[str, TrustLevel]:
28
+ """The declared source-to-trust mapping, for trust resolution at the sink."""
29
+ return {
30
+ declaration.name: declaration.trust for declaration in self.document.sources
31
+ }
32
+
33
+ @classmethod
34
+ def from_file(cls, path: str) -> Policy:
35
+ """Load, validate, and compile a policy file in one call.
36
+
37
+ Args:
38
+ path: Filesystem path to the policy YAML file.
39
+
40
+ Returns:
41
+ A `Policy` ready to pass to `configure()`.
42
+
43
+ Raises:
44
+ PolicyEvaluationError: If the file is missing, malformed, or
45
+ fails schema or CEL compilation.
46
+ """
47
+ document = load_policy_document(path)
48
+ return cls(document=document, compiled_sinks=compile_policy(document))
49
+
50
+ @classmethod
51
+ def validate(cls, path: str) -> list[str]:
52
+ """Statically analyze a policy file without loading or compiling it for use.
53
+
54
+ Args:
55
+ path: Filesystem path to the policy YAML file.
56
+
57
+ Returns:
58
+ A list of human-readable problem descriptions; empty if valid.
59
+ Never raises.
60
+ """
61
+ return validate_policy(path)
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import celpy
9
+ from celpy import celtypes
10
+ from celpy.adapter import json_to_cel
11
+
12
+ from interbolt.models.core import Action, Label, TrustLevel
13
+ from interbolt.policy.schema import PolicyDocument
14
+
15
+ _ANY_MACRO_PATTERN = re.compile(r"\.any\(")
16
+ _ENV = celpy.Environment()
17
+
18
+
19
+ def _rewrite_any_to_exists(source: str) -> str:
20
+ """Rewrite the policy DSL's `.any(` to CEL's real `exists` macro.
21
+
22
+ CEL's macro set is exactly `{map, filter, all, exists, exists_one, reduce,
23
+ min}`; there is no `any`. `exists` ("at least one element satisfies the
24
+ predicate") is semantically identical to Python's `any`, so this is a safe
25
+ textual rewrite, done once at compile time, never per evaluation.
26
+ """
27
+ return _ANY_MACRO_PATTERN.sub(".exists(", source)
28
+
29
+
30
+ def compile_cel_expression(source: str) -> celpy.Runner:
31
+ """Compile one CEL `when` expression into a reusable, evaluate-many program.
32
+
33
+ Args:
34
+ source: The CEL expression text, as written in the policy YAML.
35
+
36
+ Returns:
37
+ A compiled celpy program, ready for repeated `evaluate()` calls.
38
+
39
+ Raises:
40
+ celpy.CELParseError: If the expression is not valid CEL.
41
+ """
42
+ return _ENV.program(_ENV.compile(_rewrite_any_to_exists(source)))
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class CompiledRule:
47
+ """One compiled rule. `program is None` marks the unconditional catch-all."""
48
+
49
+ name: str
50
+ action: Action
51
+ program: celpy.Runner | None
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class CompiledSink:
56
+ """A sink's ordered, compiled rule list."""
57
+
58
+ rules: tuple[CompiledRule, ...]
59
+
60
+
61
+ def compile_policy(document: PolicyDocument) -> dict[str, CompiledSink]:
62
+ """Compile every sink's rule list once, at policy load time.
63
+
64
+ Args:
65
+ document: The validated policy document.
66
+
67
+ Returns:
68
+ A mapping of dotted sink key to its compiled rule list.
69
+ """
70
+ compiled: dict[str, CompiledSink] = {}
71
+ for sink_key, rules in document.sinks.items():
72
+ compiled_rules = tuple(
73
+ CompiledRule(
74
+ name=rule.name,
75
+ action=rule.action,
76
+ program=compile_cel_expression(rule.when)
77
+ if rule.when is not None
78
+ else None,
79
+ )
80
+ for rule in rules
81
+ )
82
+ compiled[sink_key] = CompiledSink(rules=compiled_rules)
83
+ return compiled
84
+
85
+
86
+ def resolve_label_trust(
87
+ label: Label, sources_table: Mapping[str, TrustLevel]
88
+ ) -> TrustLevel:
89
+ """Resolve a label's trust from every name in its lineage, untrusted-wins.
90
+
91
+ Trust is never stored on a `Label`; this is the one place it is computed,
92
+ at the sink, from the policy's `sources` table. A name not in the table
93
+ defaults to untrusted (default-deny).
94
+ """
95
+ for name in label.lineage:
96
+ if sources_table.get(name, TrustLevel.UNTRUSTED) is TrustLevel.UNTRUSTED:
97
+ return TrustLevel.UNTRUSTED
98
+ return TrustLevel.TRUSTED
99
+
100
+
101
+ def _convert_args(args: Mapping[str, Any]) -> celtypes.MapType:
102
+ converted: dict[celtypes.StringType, Any] = {}
103
+ for key, value in args.items():
104
+ try:
105
+ converted[celtypes.StringType(key)] = json_to_cel(value)
106
+ except (ValueError, TypeError):
107
+ # Not representable in CEL (e.g. an arbitrary object); simply
108
+ # unavailable to `when` predicates, same as a missing key.
109
+ continue
110
+ return celtypes.MapType(converted)
111
+
112
+
113
+ def build_context(
114
+ *,
115
+ tool: str,
116
+ args: Mapping[str, Any],
117
+ labels: tuple[Label, ...],
118
+ trifecta: frozenset[str],
119
+ sources_table: Mapping[str, TrustLevel],
120
+ ) -> dict[str, Any]:
121
+ """Build the CEL evaluation context for one `check()` call.
122
+
123
+ `args` must already be stripped of taint carriers (plain `str`/`bytes`/
124
+ containers) by the caller; this function has no knowledge of `Tainted`,
125
+ `TaintedBytes`, or `LabeledValue` (`policy/` never imports `taint/`).
126
+
127
+ `taint` stays a plain CEL list so `taint.any(...)`/`taint.all(...)` keep
128
+ working as macros over it. The two aggregate convenience values move to
129
+ top-level siblings, `sources` and `max_trust`, rather than `taint.sources`/
130
+ `taint.max_trust`: CEL cannot make one context variable both a list (for
131
+ the macros) and a map (for dotted field access) at once.
132
+
133
+ Args:
134
+ tool: The dotted qualified tool name.
135
+ args: The call's bound arguments, taint carriers already stripped.
136
+ labels: Every label collected from the call's original arguments.
137
+ trifecta: The trifecta legs satisfied by this call.
138
+ sources_table: The policy's declared source-to-trust mapping.
139
+
140
+ Returns:
141
+ A context mapping ready for `celpy.Runner.evaluate(...)`.
142
+ """
143
+ taint_list = celtypes.ListType(
144
+ [
145
+ celtypes.MapType(
146
+ {
147
+ celtypes.StringType("source"): celtypes.StringType(label.source),
148
+ celtypes.StringType("trust"): celtypes.StringType(
149
+ resolve_label_trust(label, sources_table).value
150
+ ),
151
+ }
152
+ )
153
+ for label in labels
154
+ ]
155
+ )
156
+
157
+ all_sources: dict[str, None] = {}
158
+ for label in labels:
159
+ for name in label.lineage:
160
+ all_sources.setdefault(name, None)
161
+
162
+ max_trust = (
163
+ TrustLevel.UNTRUSTED
164
+ if any(
165
+ resolve_label_trust(label, sources_table) is TrustLevel.UNTRUSTED
166
+ for label in labels
167
+ )
168
+ else TrustLevel.TRUSTED
169
+ )
170
+
171
+ return {
172
+ "tool": celtypes.StringType(tool),
173
+ "args": _convert_args(args),
174
+ "taint": taint_list,
175
+ "sources": celtypes.ListType(
176
+ [celtypes.StringType(name) for name in all_sources]
177
+ ),
178
+ "max_trust": celtypes.StringType(max_trust.value),
179
+ "trifecta": celtypes.ListType([celtypes.StringType(leg) for leg in trifecta]),
180
+ }
181
+
182
+
183
+ def evaluate_sink(
184
+ compiled_sink: CompiledSink, context: Mapping[str, Any], *, default_action: Action
185
+ ) -> tuple[str | None, Action]:
186
+ """Evaluate a sink's compiled rules, first match wins.
187
+
188
+ Args:
189
+ compiled_sink: The sink's compiled rule list.
190
+ context: The CEL context built by `build_context`.
191
+ default_action: The action to fall through to if no rule matches.
192
+
193
+ Returns:
194
+ The matched rule's name (or `None` for the default) and its action.
195
+
196
+ Raises:
197
+ celpy.evaluation.CELEvalError: If a rule's `when` references a missing
198
+ argument, a `None` value, or otherwise fails to evaluate.
199
+ """
200
+ for rule in compiled_sink.rules:
201
+ if rule.program is None:
202
+ return rule.name, rule.action
203
+ if bool(rule.program.evaluate(context)):
204
+ return rule.name, rule.action
205
+ return None, default_action
@@ -0,0 +1,131 @@
1
+ {
2
+ "$defs": {
3
+ "Action": {
4
+ "description": "The three possible policy decisions.",
5
+ "enum": [
6
+ "allow",
7
+ "block",
8
+ "require_approval"
9
+ ],
10
+ "title": "Action",
11
+ "type": "string"
12
+ },
13
+ "Defaults": {
14
+ "description": "Default-deny posture for sources and sinks not otherwise declared.",
15
+ "properties": {
16
+ "source_trust": {
17
+ "$ref": "#/$defs/TrustLevel",
18
+ "default": "untrusted"
19
+ },
20
+ "sink_action": {
21
+ "$ref": "#/$defs/Action",
22
+ "default": "require_approval"
23
+ },
24
+ "fail_mode": {
25
+ "default": "enforce",
26
+ "enum": [
27
+ "enforce",
28
+ "monitor",
29
+ "dry_run"
30
+ ],
31
+ "title": "Fail Mode",
32
+ "type": "string"
33
+ }
34
+ },
35
+ "title": "Defaults",
36
+ "type": "object"
37
+ },
38
+ "SinkRule": {
39
+ "description": "One ordered rule within a sink's rule list. First match wins.",
40
+ "properties": {
41
+ "name": {
42
+ "title": "Name",
43
+ "type": "string"
44
+ },
45
+ "when": {
46
+ "anyOf": [
47
+ {
48
+ "type": "string"
49
+ },
50
+ {
51
+ "type": "null"
52
+ }
53
+ ],
54
+ "default": null,
55
+ "title": "When"
56
+ },
57
+ "action": {
58
+ "$ref": "#/$defs/Action"
59
+ }
60
+ },
61
+ "required": [
62
+ "name",
63
+ "action"
64
+ ],
65
+ "title": "SinkRule",
66
+ "type": "object"
67
+ },
68
+ "SourceDeclaration": {
69
+ "description": "A declared ingress source and the trust level it resolves to.",
70
+ "properties": {
71
+ "name": {
72
+ "title": "Name",
73
+ "type": "string"
74
+ },
75
+ "trust": {
76
+ "$ref": "#/$defs/TrustLevel"
77
+ }
78
+ },
79
+ "required": [
80
+ "name",
81
+ "trust"
82
+ ],
83
+ "title": "SourceDeclaration",
84
+ "type": "object"
85
+ },
86
+ "TrustLevel": {
87
+ "description": "The result of resolving a source name against a policy's sources table.",
88
+ "enum": [
89
+ "trusted",
90
+ "untrusted"
91
+ ],
92
+ "title": "TrustLevel",
93
+ "type": "string"
94
+ }
95
+ },
96
+ "description": "The validated shape of a policy YAML file.",
97
+ "properties": {
98
+ "version": {
99
+ "title": "Version",
100
+ "type": "string"
101
+ },
102
+ "defaults": {
103
+ "$ref": "#/$defs/Defaults"
104
+ },
105
+ "sources": {
106
+ "default": [],
107
+ "items": {
108
+ "$ref": "#/$defs/SourceDeclaration"
109
+ },
110
+ "title": "Sources",
111
+ "type": "array"
112
+ },
113
+ "sinks": {
114
+ "additionalProperties": {
115
+ "items": {
116
+ "$ref": "#/$defs/SinkRule"
117
+ },
118
+ "type": "array"
119
+ },
120
+ "title": "Sinks",
121
+ "type": "object"
122
+ }
123
+ },
124
+ "required": [
125
+ "version",
126
+ "defaults",
127
+ "sinks"
128
+ ],
129
+ "title": "PolicyDocument",
130
+ "type": "object"
131
+ }
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ import yaml
6
+ from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
7
+
8
+ from interbolt.constants import TRIFECTA_COMPUTABLE_LEGS
9
+ from interbolt.errors import InterboltConfigError, PolicyEvaluationError
10
+ from interbolt.models.core import Action, Mode, TrustLevel, validate_qualified_name_part
11
+
12
+ _TRIFECTA_LEG_PATTERN = re.compile(r"trifecta\.contains\(\s*[\"']([^\"']+)[\"']\s*\)")
13
+
14
+
15
+ class SourceDeclaration(BaseModel):
16
+ """A declared ingress source and the trust level it resolves to."""
17
+
18
+ model_config = ConfigDict(frozen=True)
19
+
20
+ name: str
21
+ trust: TrustLevel
22
+
23
+
24
+ class Defaults(BaseModel):
25
+ """Default-deny posture for sources and sinks not otherwise declared."""
26
+
27
+ model_config = ConfigDict(frozen=True)
28
+
29
+ source_trust: TrustLevel = TrustLevel.UNTRUSTED
30
+ sink_action: Action = Action.REQUIRE_APPROVAL
31
+ fail_mode: Mode = Mode.ENFORCE
32
+
33
+
34
+ class SinkRule(BaseModel):
35
+ """One ordered rule within a sink's rule list. First match wins."""
36
+
37
+ model_config = ConfigDict(frozen=True)
38
+
39
+ name: str
40
+ when: str | None = None
41
+ action: Action
42
+
43
+
44
+ def _split_sink_key(key: str) -> tuple[str, str]:
45
+ namespace, separator, tool = key.rpartition(".")
46
+ if not separator:
47
+ raise InterboltConfigError(
48
+ f"sink key {key!r} must be a dotted 'namespace.tool' name"
49
+ )
50
+ validate_qualified_name_part(namespace, part="namespace")
51
+ validate_qualified_name_part(tool, part="tool")
52
+ return namespace, tool
53
+
54
+
55
+ class PolicyDocument(BaseModel):
56
+ """The validated shape of a policy YAML file."""
57
+
58
+ model_config = ConfigDict(frozen=True)
59
+
60
+ version: str
61
+ defaults: Defaults
62
+ sources: tuple[SourceDeclaration, ...] = ()
63
+ sinks: dict[str, tuple[SinkRule, ...]]
64
+
65
+ @field_validator("sinks")
66
+ @classmethod
67
+ def _validate_sink_keys(
68
+ cls, value: dict[str, tuple[SinkRule, ...]]
69
+ ) -> dict[str, tuple[SinkRule, ...]]:
70
+ for key in value:
71
+ _split_sink_key(key)
72
+ return value
73
+
74
+
75
+ def load_policy_document(path: str) -> PolicyDocument:
76
+ """Load and validate a policy YAML file against `PolicyDocument`.
77
+
78
+ Args:
79
+ path: Filesystem path to the policy YAML file.
80
+
81
+ Returns:
82
+ The validated `PolicyDocument`.
83
+
84
+ Raises:
85
+ PolicyEvaluationError: If the file cannot be read, is not valid YAML,
86
+ or does not conform to the policy schema.
87
+ """
88
+ try:
89
+ with open(path, encoding="utf-8") as handle:
90
+ data = yaml.safe_load(handle)
91
+ except (OSError, yaml.YAMLError) as exc:
92
+ raise PolicyEvaluationError(
93
+ f"failed to read policy file {path!r}: {exc}"
94
+ ) from exc
95
+ try:
96
+ return PolicyDocument.model_validate(data)
97
+ except ValidationError as exc:
98
+ raise PolicyEvaluationError(f"policy file {path!r} is invalid: {exc}") from exc
99
+
100
+
101
+ def validate_policy(path: str) -> list[str]:
102
+ """Statically analyze a policy file: schema, CEL compilation, dead rules.
103
+
104
+ Never executes an agent and never observes live taint; this is the
105
+ dynamic counterpart's opposite number (`dry_run` + the audit flag, which
106
+ are in-process instruments). Does not attempt to resolve whether every
107
+ source name referenced inside a `when` expression is declared, since that
108
+ would require walking the CEL AST for string-literal comparisons against
109
+ `t.source`; out of scope for v1's static check.
110
+
111
+ Rejects any `when` expression referencing a trifecta leg outside the
112
+ v1-computable set (`{"from_untrusted"}`), since
113
+ `trifecta.contains("reaches_external")` silently evaluates to `false` at
114
+ runtime rather than failing: a rule built on it never fires, with no
115
+ signal unless caught here.
116
+
117
+ Args:
118
+ path: Filesystem path to the policy YAML file.
119
+
120
+ Returns:
121
+ A list of human-readable problem descriptions. Empty if the policy is
122
+ valid. Never raises.
123
+ """
124
+ from interbolt.policy.engine import compile_cel_expression
125
+
126
+ problems: list[str] = []
127
+ try:
128
+ with open(path, encoding="utf-8") as handle:
129
+ data = yaml.safe_load(handle)
130
+ except (OSError, yaml.YAMLError) as exc:
131
+ return [f"failed to read policy file {path!r}: {exc}"]
132
+
133
+ try:
134
+ document = PolicyDocument.model_validate(data)
135
+ except ValidationError as exc:
136
+ for error in exc.errors():
137
+ location = ".".join(str(part) for part in error["loc"])
138
+ problems.append(f"{location}: {error['msg']}")
139
+ return problems
140
+
141
+ for sink_key, rules in document.sinks.items():
142
+ catch_all_seen = False
143
+ for rule in rules:
144
+ if catch_all_seen:
145
+ problems.append(
146
+ f"sink {sink_key!r}: rule {rule.name!r} is unreachable, "
147
+ "placed after an unconditional catch-all rule"
148
+ )
149
+ if rule.when is None:
150
+ if catch_all_seen:
151
+ problems.append(
152
+ f"sink {sink_key!r}: more than one unconditional catch-all rule"
153
+ )
154
+ catch_all_seen = True
155
+ continue
156
+ try:
157
+ compile_cel_expression(rule.when)
158
+ except Exception as exc: # noqa: BLE001 -- surfacing any compile failure
159
+ problems.append(
160
+ f"sink {sink_key!r}: rule {rule.name!r} "
161
+ f"has an invalid CEL expression: {exc}"
162
+ )
163
+ for leg in _TRIFECTA_LEG_PATTERN.findall(rule.when):
164
+ if leg not in TRIFECTA_COMPUTABLE_LEGS:
165
+ problems.append(
166
+ f"sink {sink_key!r}: rule {rule.name!r} references "
167
+ f"trifecta leg {leg!r}, which is not computed in v1 "
168
+ f"(trifecta.contains({leg!r}) always evaluates false); "
169
+ f"computable legs are {sorted(TRIFECTA_COMPUTABLE_LEGS)}"
170
+ )
171
+
172
+ return problems
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from interbolt.models.core import Decision, Event, Finding
4
+ from interbolt.utils import get_logger
5
+
6
+ _logger = get_logger("reporting")
7
+
8
+
9
+ class NullReporter:
10
+ """The default reporter: a no-op. Keeps the library fully local by default."""
11
+
12
+ def export(self, event: Event | Finding) -> None:
13
+ """Discard the record.
14
+
15
+ Args:
16
+ event: The record to discard.
17
+ """
18
+ return None
19
+
20
+
21
+ class InMemoryReporter:
22
+ """Captures every exported record in memory; the testing/audit assertion surface."""
23
+
24
+ def __init__(self) -> None:
25
+ self.events: list[Event] = []
26
+ self.decisions: list[Decision] = []
27
+ self.findings: list[Finding] = []
28
+
29
+ def export(self, event: Event | Finding) -> None:
30
+ """Capture the record.
31
+
32
+ Args:
33
+ event: The record to capture.
34
+ """
35
+ if isinstance(event, Event):
36
+ self.events.append(event)
37
+ self.decisions.append(event.decision)
38
+ else:
39
+ self.findings.append(event)
40
+
41
+ def clear(self) -> None:
42
+ """Discard every captured record."""
43
+ self.events.clear()
44
+ self.decisions.clear()
45
+ self.findings.clear()
46
+
47
+
48
+ class LoggingReporter:
49
+ """Emits every record via the library logger, at DEBUG."""
50
+
51
+ def export(self, event: Event | Finding) -> None:
52
+ """Log the record.
53
+
54
+ Args:
55
+ event: The record to log.
56
+ """
57
+ _logger.debug("export: %r", event)