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.
interbolt/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """Provenance-gated tool calls for AI agents.
2
+
3
+ Mark untrusted data where it enters an agent. interbolt records its provenance,
4
+ carries that provenance through your code, and evaluates a YAML+CEL policy at
5
+ the tool-call boundary to allow, block, or require approval. Decisions are
6
+ deterministic and local: no model in the loop, no network calls.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ from interbolt.errors import (
14
+ ApprovalDenied,
15
+ InterboltConfigError,
16
+ InterboltError,
17
+ InterboltUsageError,
18
+ PolicyEvaluationError,
19
+ PolicyViolation,
20
+ )
21
+ from interbolt.models.core import Action, Decision, Label, Mode, TrustLevel
22
+ from interbolt.models.protocols import ApprovalResolver, Reporter
23
+ from interbolt.policy import Policy
24
+ from interbolt.reporting import InMemoryReporter, LoggingReporter, NullReporter
25
+ from interbolt.runtime import Runtime, check, configure, guard
26
+ from interbolt.taint import LabeledValue, Tainted, TaintedBytes, taint
27
+
28
+ __all__ = [
29
+ "taint",
30
+ "guard",
31
+ "check",
32
+ "configure",
33
+ "Runtime",
34
+ "Policy",
35
+ "Decision",
36
+ "Action",
37
+ "Mode",
38
+ "Label",
39
+ "TrustLevel",
40
+ "Reporter",
41
+ "ApprovalResolver",
42
+ "NullReporter",
43
+ "InMemoryReporter",
44
+ "LoggingReporter",
45
+ "InterboltError",
46
+ "PolicyViolation",
47
+ "PolicyEvaluationError",
48
+ "ApprovalDenied",
49
+ "InterboltConfigError",
50
+ "InterboltUsageError",
51
+ "Tainted",
52
+ "LabeledValue",
53
+ "TaintedBytes",
54
+ "__version__",
55
+ ]
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from rich.console import Console
6
+
7
+ from interbolt import Policy
8
+
9
+ _console = Console()
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> int:
13
+ """The `interbolt` console script entrypoint.
14
+
15
+ Args:
16
+ argv: Command-line arguments, or `None` to use `sys.argv[1:]`.
17
+
18
+ Returns:
19
+ The process exit code: 0 on success, 1 on failure.
20
+ """
21
+ parser = argparse.ArgumentParser(prog="interbolt")
22
+ subparsers = parser.add_subparsers(dest="command", required=True)
23
+
24
+ validate_parser = subparsers.add_parser(
25
+ "validate", help="Static policy analysis only. Never executes an agent."
26
+ )
27
+ validate_parser.add_argument("policy_path")
28
+
29
+ args = parser.parse_args(argv)
30
+
31
+ if args.command == "validate":
32
+ return _validate(args.policy_path)
33
+ return 1
34
+
35
+
36
+ def _validate(policy_path: str) -> int:
37
+ problems = Policy.validate(policy_path)
38
+ if problems:
39
+ for problem in problems:
40
+ _console.print(f"[red]✗[/red] {problem}")
41
+ return 1
42
+ _console.print(f"[green]✓[/green] {policy_path} is valid")
43
+ return 0
interbolt/constants.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from interbolt.errors import InterboltConfigError
6
+
7
+ DEFAULT_NAMESPACE: str = "default"
8
+ DEFAULT_AGENT_ID: str = "default"
9
+
10
+ ENV_MODE: str = "INTERBOLT_MODE"
11
+ ENV_AUDIT: str = "INTERBOLT_AUDIT"
12
+ ENV_RECURSION_DEPTH: str = "INTERBOLT_RECURSION_DEPTH"
13
+ ENV_CACHE_DIR: str = "INTERBOLT_CACHE_DIR"
14
+
15
+ DEFAULT_RECURSION_DEPTH: int = 4
16
+ RECURSION_DEPTH_MAX: int = 10
17
+ EVENT_SCHEMA_VERSION: int = 1
18
+ AUDIT_MIN_MATCH_LENGTH: int = 12
19
+
20
+ TRIFECTA_FROM_UNTRUSTED: str = "from_untrusted"
21
+ TRIFECTA_COMPUTABLE_LEGS: frozenset[str] = frozenset({TRIFECTA_FROM_UNTRUSTED})
22
+
23
+
24
+ def _resolve_recursion_depth() -> int:
25
+ """Resolve the container-recursion depth once, at import time.
26
+
27
+ Reads `INTERBOLT_RECURSION_DEPTH`, falling back to `DEFAULT_RECURSION_DEPTH`.
28
+ Both `taint()` and `check()`/`guard` read the resulting constant, so
29
+ ingress labeling and sink collection are bounded identically (§6.6).
30
+
31
+ Raises:
32
+ InterboltConfigError: If the env var is set but is not an integer in
33
+ `[1, RECURSION_DEPTH_MAX]`.
34
+ """
35
+ raw = os.environ.get(ENV_RECURSION_DEPTH)
36
+ if raw is None:
37
+ return DEFAULT_RECURSION_DEPTH
38
+ try:
39
+ depth = int(raw)
40
+ except ValueError as exc:
41
+ raise InterboltConfigError(
42
+ f"{ENV_RECURSION_DEPTH}={raw!r} is not an integer"
43
+ ) from exc
44
+ if not (1 <= depth <= RECURSION_DEPTH_MAX):
45
+ raise InterboltConfigError(
46
+ f"{ENV_RECURSION_DEPTH}={raw!r} must be in [1, {RECURSION_DEPTH_MAX}]"
47
+ )
48
+ return depth
49
+
50
+
51
+ RECURSION_DEPTH: int = _resolve_recursion_depth()
@@ -0,0 +1,302 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from collections.abc import Generator, Mapping
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ from celpy.evaluation import CELEvalError
9
+
10
+ from interbolt.constants import (
11
+ AUDIT_MIN_MATCH_LENGTH,
12
+ EVENT_SCHEMA_VERSION,
13
+ RECURSION_DEPTH,
14
+ TRIFECTA_FROM_UNTRUSTED,
15
+ )
16
+ from interbolt.errors import PolicyEvaluationError
17
+ from interbolt.models.core import (
18
+ Action,
19
+ Decision,
20
+ Event,
21
+ Finding,
22
+ Label,
23
+ Mode,
24
+ TrustLevel,
25
+ )
26
+ from interbolt.models.protocols import Reporter
27
+ from interbolt.policy import Policy
28
+ from interbolt.policy.engine import build_context, evaluate_sink, resolve_label_trust
29
+ from interbolt.taint import Tainted, TaintedBytes, collect_labels, unwrap
30
+ from interbolt.utils import get_logger
31
+
32
+ _logger = get_logger("enforcement")
33
+ _CONTAINER_TYPES = (list, tuple, set, frozenset)
34
+
35
+
36
+ def check(
37
+ *,
38
+ tool: str,
39
+ args: Mapping[str, Any],
40
+ agent_id: str,
41
+ run_id: str | None,
42
+ session_id: str | None,
43
+ policy: Policy,
44
+ reporter: Reporter,
45
+ mode: Mode,
46
+ audit_registry: AuditRegistry | None = None,
47
+ ) -> Decision:
48
+ """Evaluate policy for one guarded call. The single decision entrypoint.
49
+
50
+ Pure with respect to the decision itself; the only side effects are the
51
+ fire-and-forget reporter emission and, when an audit registry is given,
52
+ the laundering scan. `guard` is sugar over this function and never
53
+ duplicates this sequence.
54
+
55
+ Note: this function never raises `PolicyViolation` or `ApprovalDenied`
56
+ for a normally-computed `block`/`require_approval` decision; it returns
57
+ the `Decision` and leaves enforcing it (raising, invoking the approval
58
+ resolver) to the caller, exactly as `guard` does. It only raises
59
+ directly for a genuine policy *evaluation* failure under `enforce` mode.
60
+
61
+ Args:
62
+ tool: The dotted qualified tool name.
63
+ args: The call's bound arguments.
64
+ agent_id: The durable agent identity.
65
+ run_id: The per-run identity, or `None` to mint a fresh one.
66
+ session_id: The optional session identity.
67
+ policy: The compiled policy to evaluate against.
68
+ reporter: Where to emit the resulting `Event`.
69
+ mode: The enforcement mode in effect.
70
+ audit_registry: The laundering-audit registry, or `None` if the
71
+ audit instrument is disabled.
72
+
73
+ Returns:
74
+ The computed `Decision`.
75
+
76
+ Raises:
77
+ PolicyEvaluationError: Under `enforce` mode, if policy evaluation
78
+ itself fails (a missing argument, a `None` value, or another CEL
79
+ evaluation error).
80
+ """
81
+ labels = collect_labels(args, max_depth=RECURSION_DEPTH)
82
+ plain_args = unwrap(args)
83
+ sources_table = policy.sources_table
84
+ trifecta = _compute_trifecta(labels, sources_table)
85
+ compiled_sink = policy.compiled_sinks.get(tool)
86
+ resolved_run_id = run_id or str(uuid.uuid4())
87
+
88
+ matched_rule: str | None = None
89
+ action: Action = policy.document.defaults.sink_action
90
+ evaluation_error: CELEvalError | None = None
91
+ try:
92
+ context = build_context(
93
+ tool=tool,
94
+ args=plain_args,
95
+ labels=labels,
96
+ trifecta=trifecta,
97
+ sources_table=sources_table,
98
+ )
99
+ if compiled_sink is not None:
100
+ matched_rule, action = evaluate_sink(
101
+ compiled_sink,
102
+ context,
103
+ default_action=policy.document.defaults.sink_action,
104
+ )
105
+ except CELEvalError as exc:
106
+ evaluation_error = exc
107
+
108
+ raw_action = action
109
+ outcome = action.value
110
+ if evaluation_error is not None:
111
+ outcome = "evaluation_error"
112
+ matched_rule = None
113
+ raw_action = Action.BLOCK if mode == Mode.ENFORCE else Action.ALLOW
114
+
115
+ final_action = Action.ALLOW if mode == Mode.DRY_RUN else raw_action
116
+
117
+ decision = Decision(
118
+ action=final_action,
119
+ matched_rule=matched_rule,
120
+ tool=tool,
121
+ contributing_labels=labels,
122
+ trifecta=trifecta,
123
+ mode=mode,
124
+ decision_id=str(uuid.uuid4()),
125
+ agent_id=agent_id,
126
+ run_id=resolved_run_id,
127
+ session_id=session_id,
128
+ )
129
+
130
+ all_sources = frozenset(name for label in labels for name in label.lineage)
131
+ event = Event(
132
+ schema_version=EVENT_SCHEMA_VERSION,
133
+ decision=decision,
134
+ agent_id=agent_id,
135
+ run_id=resolved_run_id,
136
+ session_id=session_id,
137
+ sources=all_sources,
138
+ lineage=tuple(sorted(all_sources)),
139
+ matched_rule=matched_rule,
140
+ trifecta=trifecta,
141
+ mode=mode,
142
+ outcome=outcome,
143
+ timestamp=datetime.now(UTC),
144
+ )
145
+ _emit(reporter, event)
146
+
147
+ if audit_registry is not None:
148
+ audit_registry.register_from_args(
149
+ args,
150
+ sources_table=sources_table,
151
+ run_id=resolved_run_id,
152
+ depth=RECURSION_DEPTH,
153
+ )
154
+ findings = audit_registry.scan(
155
+ args,
156
+ tool=tool,
157
+ run_id=resolved_run_id,
158
+ agent_id=agent_id,
159
+ session_id=session_id,
160
+ depth=RECURSION_DEPTH,
161
+ )
162
+ for finding in findings:
163
+ _emit(reporter, finding)
164
+
165
+ if evaluation_error is not None and mode == Mode.ENFORCE:
166
+ raise PolicyEvaluationError(str(evaluation_error), decision=decision)
167
+
168
+ return decision
169
+
170
+
171
+ def _emit(reporter: Reporter, event: Event | Finding) -> None:
172
+ try:
173
+ reporter.export(event)
174
+ except Exception: # noqa: BLE001 -- a reporter failure must never affect a decision
175
+ _logger.warning(
176
+ "reporter %r failed to export %r", reporter, type(event).__name__
177
+ )
178
+
179
+
180
+ def _compute_trifecta(
181
+ labels: tuple[Label, ...], sources_table: Mapping[str, TrustLevel]
182
+ ) -> frozenset[str]:
183
+ """Compute the lethal-trifecta legs satisfied by this call.
184
+
185
+ v1 computes `from_untrusted` only. The `reaches_external` and
186
+ `reads_private` legs are not computed in v1 (the latter requires the
187
+ deferred capabilities declaration); `trifecta.contains("reaches_external")`
188
+ always evaluates false. Do not rely on a v1 trifecta size as a backstop.
189
+ """
190
+ if any(
191
+ resolve_label_trust(label, sources_table) is TrustLevel.UNTRUSTED
192
+ for label in labels
193
+ ):
194
+ return frozenset({TRIFECTA_FROM_UNTRUSTED})
195
+ return frozenset()
196
+
197
+
198
+ def _walk_strings(
199
+ value: Any,
200
+ *,
201
+ depth: int, # noqa: ANN401 -- arbitrary bound-argument value
202
+ ) -> Generator[tuple[str, Label | None], None, None]:
203
+ """Yield every string leaf in `value`: `(content, label)`.
204
+
205
+ `label` is `None` for a plain `str` (a potential laundering point) and
206
+ set for a `Tainted`/`TaintedBytes` leaf (already labeled, not a
207
+ laundering point). Recurses into builtin containers to `depth`.
208
+ """
209
+ if isinstance(value, (Tainted, TaintedBytes)):
210
+ content = (
211
+ value if isinstance(value, str) else value.decode("utf-8", errors="ignore")
212
+ )
213
+ yield str(content), value.label
214
+ return
215
+ if isinstance(value, str):
216
+ yield value, None
217
+ return
218
+ if depth <= 0:
219
+ return
220
+ if isinstance(value, Mapping):
221
+ for v in value.values():
222
+ yield from _walk_strings(v, depth=depth - 1)
223
+ return
224
+ if isinstance(value, _CONTAINER_TYPES):
225
+ for item in value:
226
+ yield from _walk_strings(item, depth=depth - 1)
227
+
228
+
229
+ class AuditRegistry:
230
+ """The laundering audit's per-run registry of untrusted-resolving content.
231
+
232
+ Advisory only: findings never change a decision. Off the latency budget;
233
+ only ever invoked when audit is enabled. Catches mechanical laundering
234
+ (the bytes survive into a sink argument unlabeled); cannot catch semantic
235
+ laundering (a model paraphrasing the untrusted text first).
236
+ """
237
+
238
+ def __init__(self, *, min_match_length: int = AUDIT_MIN_MATCH_LENGTH) -> None:
239
+ self._min_match_length = min_match_length
240
+ self._by_run: dict[str, list[tuple[str, str]]] = {}
241
+ self._findings: list[Finding] = []
242
+
243
+ def register_from_args(
244
+ self,
245
+ args: Mapping[str, Any],
246
+ *,
247
+ sources_table: Mapping[str, TrustLevel],
248
+ run_id: str,
249
+ depth: int,
250
+ ) -> None:
251
+ """Register every untrusted-resolving string found in `args` for this run."""
252
+ for value in args.values():
253
+ for content, label in _walk_strings(value, depth=depth):
254
+ if label is None or len(content) < self._min_match_length:
255
+ continue
256
+ if resolve_label_trust(label, sources_table) is TrustLevel.UNTRUSTED:
257
+ self._by_run.setdefault(run_id, []).append((content, label.source))
258
+
259
+ def scan(
260
+ self,
261
+ args: Mapping[str, Any],
262
+ *,
263
+ tool: str,
264
+ run_id: str,
265
+ agent_id: str,
266
+ session_id: str | None,
267
+ depth: int,
268
+ ) -> list[Finding]:
269
+ """Scan `args` for previously-registered untrusted content with no label."""
270
+ registered = self._by_run.get(run_id, [])
271
+ if not registered:
272
+ return []
273
+ findings: list[Finding] = []
274
+ for argument, value in args.items():
275
+ for content, label in _walk_strings(value, depth=depth):
276
+ if label is not None:
277
+ continue
278
+ for registered_content, source in registered:
279
+ if registered_content in content:
280
+ findings.append(
281
+ Finding(
282
+ schema_version=EVENT_SCHEMA_VERSION,
283
+ source=source,
284
+ tool=tool,
285
+ argument=argument,
286
+ agent_id=agent_id,
287
+ run_id=run_id,
288
+ session_id=session_id,
289
+ timestamp=datetime.now(UTC),
290
+ )
291
+ )
292
+ self._findings.extend(findings)
293
+ return findings
294
+
295
+ def clear_run(self, run_id: str) -> None:
296
+ """Drop the registered content for a finished run."""
297
+ self._by_run.pop(run_id, None)
298
+
299
+ @property
300
+ def findings(self) -> list[Finding]:
301
+ """Every finding recorded so far, across all runs."""
302
+ return list(self._findings)
interbolt/errors.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from interbolt.models.core import Decision
7
+
8
+
9
+ class InterboltError(Exception):
10
+ """Base class for every exception interbolt raises as part of a policy decision."""
11
+
12
+
13
+ class PolicyViolation(InterboltError):
14
+ """Raised when a guarded call is blocked by policy.
15
+
16
+ Attributes:
17
+ decision: The `Decision` that produced the block.
18
+ """
19
+
20
+ def __init__(self, message: str, *, decision: Decision) -> None:
21
+ super().__init__(message)
22
+ self.decision = decision
23
+
24
+
25
+ class PolicyEvaluationError(InterboltError):
26
+ """Raised when policy evaluation itself fails.
27
+
28
+ Covers a malformed policy file at load time, and a CEL evaluation error
29
+ (missing argument, `None` value, non-marshalable value) under `enforce` mode
30
+ at call time.
31
+
32
+ Attributes:
33
+ decision: The partial `Decision`, if one was assembled before the error.
34
+ `None` for load-time failures, where no call context exists yet.
35
+ """
36
+
37
+ def __init__(self, message: str, *, decision: Decision | None = None) -> None:
38
+ super().__init__(message)
39
+ self.decision = decision
40
+
41
+
42
+ class ApprovalDenied(InterboltError):
43
+ """Raised when a `require_approval` decision is denied by the approval resolver.
44
+
45
+ Attributes:
46
+ decision: The `Decision` that required approval.
47
+ """
48
+
49
+ def __init__(self, message: str, *, decision: Decision) -> None:
50
+ super().__init__(message)
51
+ self.decision = decision
52
+
53
+
54
+ class InterboltConfigError(InterboltError, ValueError):
55
+ """Raised for an invalid configuration value.
56
+
57
+ Examples: an unrecognized `mode`, an out-of-range
58
+ `INTERBOLT_RECURSION_DEPTH`, a tool or namespace name containing a dot.
59
+ Subclasses `ValueError` as well as `InterboltError`, so callers can catch it
60
+ either as interbolt's own type or by its builtin semantics.
61
+ """
62
+
63
+
64
+ class InterboltUsageError(InterboltError, RuntimeError):
65
+ """Raised when the public API is used out of sequence.
66
+
67
+ Example: calling `check()`/`guard` before `configure()` has run.
68
+ Subclasses `RuntimeError` as well as `InterboltError`, so callers can catch
69
+ it either as interbolt's own type or by its builtin semantics.
70
+ """
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from enum import StrEnum
5
+
6
+ from pydantic import BaseModel, ConfigDict, field_validator
7
+
8
+ from interbolt.errors import InterboltConfigError
9
+
10
+
11
+ def validate_qualified_name_part(value: str, *, part: str) -> None:
12
+ """Reject a namespace or tool name that contains a dot.
13
+
14
+ The dotted `namespace.tool` form is the policy-key surface, so neither half
15
+ may itself contain a dot or the two forms become ambiguous to parse back apart.
16
+
17
+ Args:
18
+ value: The candidate namespace or tool name.
19
+ part: Which part this is, for the error message ("namespace" or "tool").
20
+
21
+ Raises:
22
+ InterboltConfigError: If `value` contains a dot.
23
+ """
24
+ if "." in value:
25
+ raise InterboltConfigError(f"{part} {value!r} may not contain a dot")
26
+
27
+
28
+ class Mode(StrEnum):
29
+ """The enforcement mode: governs behavior on evaluation error.
30
+
31
+ Does not change a correct `block`/`require_approval` decision, except
32
+ under `DRY_RUN` where every decision is downgraded to allow.
33
+ """
34
+
35
+ ENFORCE = "enforce"
36
+ MONITOR = "monitor"
37
+ DRY_RUN = "dry_run"
38
+
39
+
40
+ class TrustLevel(StrEnum):
41
+ """The result of resolving a source name against a policy's sources table."""
42
+
43
+ TRUSTED = "trusted"
44
+ UNTRUSTED = "untrusted"
45
+
46
+
47
+ class Label(BaseModel):
48
+ """Provenance attached to a value: where it came from, never a resolved trust bit.
49
+
50
+ Attributes:
51
+ source: The originating source name. For a merged value, the first
52
+ contributing source (lineage carries the full set).
53
+ value_id: A unique id minted when this label was created or last
54
+ transformed, for the flow graph and audit trail.
55
+ lineage: The de-duplicated set of source names that contributed to this
56
+ value, in first-contributed order.
57
+ """
58
+
59
+ model_config = ConfigDict(frozen=True)
60
+
61
+ source: str
62
+ value_id: str
63
+ lineage: tuple[str, ...]
64
+
65
+
66
+ class Action(StrEnum):
67
+ """The three possible policy decisions."""
68
+
69
+ ALLOW = "allow"
70
+ BLOCK = "block"
71
+ REQUIRE_APPROVAL = "require_approval"
72
+
73
+
74
+ class Decision(BaseModel):
75
+ """The outcome of evaluating a policy against a guarded call.
76
+
77
+ Attributes:
78
+ action: The decision taken.
79
+ matched_rule: The name of the first matching rule, or `None` if the
80
+ sink's default action was used.
81
+ tool: The qualified tool name the decision was made for.
82
+ contributing_labels: Every label collected from the call's arguments.
83
+ trifecta: The lethal-trifecta legs satisfied by this call. In v1 this
84
+ only ever contains `"from_untrusted"` or is empty; the
85
+ `reaches_external` and `reads_private` legs are not computed.
86
+ mode: The enforcement mode in effect when this decision was made.
87
+ decision_id: A unique id for this decision, for the audit trail.
88
+ agent_id: The durable, integrator-supplied agent identity.
89
+ run_id: The per-run identity.
90
+ session_id: The optional, integrator-supplied session identity.
91
+ """
92
+
93
+ model_config = ConfigDict(frozen=True)
94
+
95
+ action: Action
96
+ matched_rule: str | None
97
+ tool: str
98
+ contributing_labels: tuple[Label, ...]
99
+ trifecta: frozenset[str]
100
+ mode: Mode
101
+ decision_id: str
102
+ agent_id: str
103
+ run_id: str
104
+ session_id: str | None
105
+
106
+
107
+ class QualifiedName(BaseModel):
108
+ """A structured `(namespace, tool)` pair; `namespace.tool` is the surface form."""
109
+
110
+ model_config = ConfigDict(frozen=True)
111
+
112
+ namespace: str
113
+ tool: str
114
+
115
+ @field_validator("namespace")
116
+ @classmethod
117
+ def _validate_namespace(cls, value: str) -> str:
118
+ validate_qualified_name_part(value, part="namespace")
119
+ return value
120
+
121
+ @field_validator("tool")
122
+ @classmethod
123
+ def _validate_tool(cls, value: str) -> str:
124
+ validate_qualified_name_part(value, part="tool")
125
+ return value
126
+
127
+ def __str__(self) -> str:
128
+ return f"{self.namespace}.{self.tool}"
129
+
130
+
131
+ class Event(BaseModel):
132
+ """The versioned, emitted record of a `Decision`."""
133
+
134
+ model_config = ConfigDict(frozen=True)
135
+
136
+ schema_version: int
137
+ decision: Decision
138
+ agent_id: str
139
+ run_id: str
140
+ session_id: str | None
141
+ sources: frozenset[str]
142
+ lineage: tuple[str, ...]
143
+ matched_rule: str | None
144
+ trifecta: frozenset[str]
145
+ mode: Mode
146
+ outcome: str
147
+ timestamp: datetime
148
+
149
+
150
+ class Finding(BaseModel):
151
+ """A laundering-audit record: untrusted content reached a sink without a label."""
152
+
153
+ model_config = ConfigDict(frozen=True)
154
+
155
+ schema_version: int
156
+ source: str
157
+ tool: str
158
+ argument: str
159
+ agent_id: str
160
+ run_id: str
161
+ session_id: str | None
162
+ timestamp: datetime