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 +55 -0
- interbolt/cli/__init__.py +43 -0
- interbolt/constants.py +51 -0
- interbolt/enforcement/__init__.py +302 -0
- interbolt/errors.py +70 -0
- interbolt/models/__init__.py +1 -0
- interbolt/models/core.py +162 -0
- interbolt/models/protocols.py +56 -0
- interbolt/policy/__init__.py +61 -0
- interbolt/policy/engine.py +205 -0
- interbolt/policy/schema.json +131 -0
- interbolt/policy/schema.py +172 -0
- interbolt/reporting/__init__.py +57 -0
- interbolt/runtime/__init__.py +259 -0
- interbolt/runtime/guard.py +176 -0
- interbolt/taint/__init__.py +355 -0
- interbolt/utils/__init__.py +43 -0
- interbolt-0.1.0.dist-info/METADATA +104 -0
- interbolt-0.1.0.dist-info/RECORD +22 -0
- interbolt-0.1.0.dist-info/WHEEL +4 -0
- interbolt-0.1.0.dist-info/entry_points.txt +2 -0
- interbolt-0.1.0.dist-info/licenses/LICENSE +201 -0
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
|
interbolt/models/core.py
ADDED
|
@@ -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
|