capability-reasoning-kernel 0.4.1__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.
- capability_reasoning_kernel-0.4.1.dist-info/METADATA +256 -0
- capability_reasoning_kernel-0.4.1.dist-info/RECORD +48 -0
- capability_reasoning_kernel-0.4.1.dist-info/WHEEL +4 -0
- capability_reasoning_kernel-0.4.1.dist-info/entry_points.txt +2 -0
- capability_reasoning_kernel-0.4.1.dist-info/licenses/LICENSE +21 -0
- reasoning_kernel/__init__.py +80 -0
- reasoning_kernel/config.py +48 -0
- reasoning_kernel/context/__init__.py +0 -0
- reasoning_kernel/context/assembler.py +66 -0
- reasoning_kernel/demo/__init__.py +0 -0
- reasoning_kernel/demo/_report.py +32 -0
- reasoning_kernel/demo/email_exfil.py +203 -0
- reasoning_kernel/demo/live_run.py +86 -0
- reasoning_kernel/demo/merge.py +86 -0
- reasoning_kernel/demo/reasoner_error.py +94 -0
- reasoning_kernel/demo/run_limits.py +48 -0
- reasoning_kernel/demo/subkernel.py +135 -0
- reasoning_kernel/kernel/__init__.py +0 -0
- reasoning_kernel/kernel/effects.py +90 -0
- reasoning_kernel/kernel/gate.py +88 -0
- reasoning_kernel/kernel/interpreter.py +238 -0
- reasoning_kernel/kernel/taint.py +68 -0
- reasoning_kernel/memory/__init__.py +0 -0
- reasoning_kernel/memory/store.py +70 -0
- reasoning_kernel/memory/trace.py +23 -0
- reasoning_kernel/py.typed +0 -0
- reasoning_kernel/reasoner/__init__.py +0 -0
- reasoning_kernel/reasoner/anthropic.py +78 -0
- reasoning_kernel/reasoner/base.py +58 -0
- reasoning_kernel/reasoner/deepseek.py +26 -0
- reasoning_kernel/reasoner/factory.py +39 -0
- reasoning_kernel/reasoner/fake.py +56 -0
- reasoning_kernel/reasoner/openai.py +126 -0
- reasoning_kernel/reasoner/parse.py +52 -0
- reasoning_kernel/reasoner/roles.py +92 -0
- reasoning_kernel/schemas/__init__.py +0 -0
- reasoning_kernel/schemas/capability.py +50 -0
- reasoning_kernel/schemas/ids.py +8 -0
- reasoning_kernel/schemas/limits.py +23 -0
- reasoning_kernel/schemas/plan.py +143 -0
- reasoning_kernel/schemas/policy.py +64 -0
- reasoning_kernel/schemas/provenance.py +63 -0
- reasoning_kernel/schemas/registry.py +41 -0
- reasoning_kernel/schemas/trace.py +110 -0
- reasoning_kernel/schemas/values.py +28 -0
- reasoning_kernel/tools/__init__.py +0 -0
- reasoning_kernel/tools/demo_mail.py +213 -0
- reasoning_kernel/tools/registry.py +44 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The effect dispatcher — the one and only place a real tool callable is invoked.
|
|
2
|
+
|
|
3
|
+
This is where the no-bypass guarantee becomes structural rather than conventional:
|
|
4
|
+
- it is the sole holder of the registry's callables;
|
|
5
|
+
- it CANNOT be constructed without a ``Gate`` (the gate is a required constructor argument);
|
|
6
|
+
- ``dispatch`` calls ``gate.check`` unconditionally and raises ``EffectBlocked`` *before* the
|
|
7
|
+
callable is ever reached.
|
|
8
|
+
|
|
9
|
+
So "no effect bypasses the Verifier" reduces to "the only effect site always checks first" —
|
|
10
|
+
which is true by construction, and witnessed in every trace.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from reasoning_kernel.kernel.gate import Gate
|
|
16
|
+
from reasoning_kernel.kernel.taint import result_label
|
|
17
|
+
from reasoning_kernel.memory.trace import TraceWriter
|
|
18
|
+
from reasoning_kernel.schemas.capability import CapabilitySet
|
|
19
|
+
from reasoning_kernel.schemas.ids import StepId
|
|
20
|
+
from reasoning_kernel.schemas.policy import RunContext, VerifierVerdict
|
|
21
|
+
from reasoning_kernel.schemas.registry import ToolSpec
|
|
22
|
+
from reasoning_kernel.schemas.trace import EffectBlockedEvent, EffectCommitted, GateDecision, digest
|
|
23
|
+
from reasoning_kernel.schemas.values import TaintedValue
|
|
24
|
+
from reasoning_kernel.tools.registry import ToolRegistry
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EffectBlocked(Exception):
|
|
28
|
+
"""Raised when the Verifier denies a call. Carries the verdict; the callable did not run."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, verdict: VerifierVerdict) -> None:
|
|
31
|
+
super().__init__(verdict.reason)
|
|
32
|
+
self.verdict = verdict
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EffectDispatcher:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
registry: ToolRegistry,
|
|
39
|
+
gate: Gate,
|
|
40
|
+
trace: TraceWriter,
|
|
41
|
+
ctx: RunContext,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._registry = registry
|
|
44
|
+
self._gate = gate
|
|
45
|
+
self._trace = trace
|
|
46
|
+
self._ctx = ctx
|
|
47
|
+
|
|
48
|
+
def catalog(self) -> list[ToolSpec]:
|
|
49
|
+
return self._registry.catalog()
|
|
50
|
+
|
|
51
|
+
def grant(self) -> CapabilitySet:
|
|
52
|
+
"""The capability grant the Gate enforces (the run's authority ceiling)."""
|
|
53
|
+
return self._gate.grant
|
|
54
|
+
|
|
55
|
+
def for_subkernel(self, grant: CapabilitySet, ctx: RunContext) -> EffectDispatcher:
|
|
56
|
+
"""A dispatcher over the SAME registry and shared trace, at a reduced grant (a sub-kernel).
|
|
57
|
+
|
|
58
|
+
Same registry means the inner kernel cannot reach a callable the outer one couldn't; the
|
|
59
|
+
clamped Gate means it can authorize strictly less. Every effect still routes through a Gate.
|
|
60
|
+
"""
|
|
61
|
+
return EffectDispatcher(self._registry, self._gate.for_grant(grant), self._trace, ctx)
|
|
62
|
+
|
|
63
|
+
def dispatch(self, tool_name: str, named_args: dict[str, TaintedValue]) -> TaintedValue:
|
|
64
|
+
rtool = self._registry.get(tool_name)
|
|
65
|
+
spec = rtool.spec
|
|
66
|
+
arg_labels = [v.label for v in named_args.values()]
|
|
67
|
+
|
|
68
|
+
verdict = self._gate.check(spec, named_args, self._ctx)
|
|
69
|
+
self._trace.emit(
|
|
70
|
+
GateDecision(
|
|
71
|
+
run_id=self._ctx.run_id, tool=spec.name, verdict=verdict, arg_labels=arg_labels
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
if not verdict.allowed:
|
|
75
|
+
self._trace.emit(
|
|
76
|
+
EffectBlockedEvent(run_id=self._ctx.run_id, tool=spec.name, verdict=verdict)
|
|
77
|
+
)
|
|
78
|
+
raise EffectBlocked(verdict)
|
|
79
|
+
|
|
80
|
+
# Past the gate: build the validated input model and invoke the real callable.
|
|
81
|
+
model_in = spec.input_schema(**{k: v.value for k, v in named_args.items()})
|
|
82
|
+
out = rtool.callable(model_in)
|
|
83
|
+
self._trace.emit(
|
|
84
|
+
EffectCommitted(run_id=self._ctx.run_id, tool=spec.name, output_digest=digest(out))
|
|
85
|
+
)
|
|
86
|
+
return TaintedValue(
|
|
87
|
+
value=out,
|
|
88
|
+
label=result_label(spec, arg_labels),
|
|
89
|
+
produced_by=StepId(f"__effect__{spec.name}"),
|
|
90
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The Verifier — the single logical Invariant-B boundary.
|
|
2
|
+
|
|
3
|
+
Every consequential call is checked here, deterministically, in three stages:
|
|
4
|
+
1. capability — the grant must contain every capability the tool requires;
|
|
5
|
+
2. schema — the arguments must validate against the tool's input schema;
|
|
6
|
+
3. provenance — for a WRITE, no tainted argument may flow into a required capability unless
|
|
7
|
+
the (deterministic) declassification policy explicitly allows it.
|
|
8
|
+
|
|
9
|
+
There is no LLM on this path: verification never depends on trusting a probabilistic component
|
|
10
|
+
(the paper's §6.2 distinction between a real, deterministic boundary and a probabilistic one).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
17
|
+
from reasoning_kernel.schemas.capability import CapabilitySet, EffectLevel
|
|
18
|
+
from reasoning_kernel.schemas.policy import DeclassPolicy, RunContext, VerifierVerdict
|
|
19
|
+
from reasoning_kernel.schemas.registry import ToolSpec
|
|
20
|
+
from reasoning_kernel.schemas.values import TaintedValue
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Gate:
|
|
24
|
+
def __init__(self, grant: CapabilitySet, declass: DeclassPolicy) -> None:
|
|
25
|
+
self._grant = grant
|
|
26
|
+
self._declass = declass
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def grant(self) -> CapabilitySet:
|
|
30
|
+
return self._grant
|
|
31
|
+
|
|
32
|
+
def for_grant(self, grant: CapabilitySet) -> Gate:
|
|
33
|
+
"""A Gate with the same declassification policy at a reduced grant (for sub-kernels)."""
|
|
34
|
+
return Gate(grant, self._declass)
|
|
35
|
+
|
|
36
|
+
def check(
|
|
37
|
+
self,
|
|
38
|
+
spec: ToolSpec,
|
|
39
|
+
named_args: dict[str, TaintedValue],
|
|
40
|
+
ctx: RunContext,
|
|
41
|
+
) -> VerifierVerdict:
|
|
42
|
+
# 1. capability
|
|
43
|
+
missing = sorted(c.name for c in spec.required_caps if not self._grant.allows(c))
|
|
44
|
+
if missing:
|
|
45
|
+
return VerifierVerdict(
|
|
46
|
+
allowed=False,
|
|
47
|
+
reason=f"missing capabilities for {spec.name}",
|
|
48
|
+
issues=[f"not granted: {m}" for m in missing],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# 2. schema
|
|
52
|
+
try:
|
|
53
|
+
spec.input_schema(**{k: v.value for k, v in named_args.items()})
|
|
54
|
+
except ValidationError as exc:
|
|
55
|
+
return VerifierVerdict(
|
|
56
|
+
allowed=False,
|
|
57
|
+
reason=f"arguments do not satisfy {spec.input_schema.__name__}",
|
|
58
|
+
issues=[str(exc)],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# 3. provenance (only matters for WRITE effects)
|
|
62
|
+
if spec.effect_level >= EffectLevel.WRITE:
|
|
63
|
+
tainted = [v for v in named_args.values() if v.label.is_tainted]
|
|
64
|
+
has_third_party = any(v.label.has_third_party for v in named_args.values())
|
|
65
|
+
if tainted or has_third_party:
|
|
66
|
+
# Permitted without declassification ONLY if the tool declares capabilities, every
|
|
67
|
+
# tainted arg may flow into all of them, AND no argument carries third-party data.
|
|
68
|
+
# Two reasons it is otherwise routed to declassification:
|
|
69
|
+
# - an empty required_caps set is not a free pass (capless WRITE would exfiltrate);
|
|
70
|
+
# - third-party data must never be auto-permitted out, regardless of readers.
|
|
71
|
+
permitted = (
|
|
72
|
+
not has_third_party
|
|
73
|
+
and bool(spec.required_caps)
|
|
74
|
+
and all(
|
|
75
|
+
v.label.allows_reader(cap) for cap in spec.required_caps for v in tainted
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
if not permitted:
|
|
79
|
+
verdict = self._declass.may_declassify(spec, named_args, ctx)
|
|
80
|
+
if not verdict.allowed:
|
|
81
|
+
return VerifierVerdict(
|
|
82
|
+
allowed=False,
|
|
83
|
+
reason=f"untrusted-derived data may not flow into {spec.name}",
|
|
84
|
+
issues=verdict.issues or [verdict.reason],
|
|
85
|
+
)
|
|
86
|
+
return verdict
|
|
87
|
+
|
|
88
|
+
return VerifierVerdict(allowed=True, reason=f"{spec.name} permitted")
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""The Conductor — the execution loop. Governs flow; does not reason.
|
|
2
|
+
|
|
3
|
+
It assembles the planner context (Invariant A), obtains a typed ``Plan``, and walks the steps.
|
|
4
|
+
``ToolCallStep`` is the only step kind that can reach reality, and it reaches it *only* through
|
|
5
|
+
the ``EffectDispatcher`` (which always checks the Gate first). The Conductor never holds a tool
|
|
6
|
+
callable and never calls a model except via the two reasoner roles. It is responsible for
|
|
7
|
+
termination (``RunLimits``) and fails closed on any plan it cannot safely execute.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import concurrent.futures as futures
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ValidationError
|
|
16
|
+
|
|
17
|
+
from reasoning_kernel.context.assembler import build_planner_context, build_quarantine_context
|
|
18
|
+
from reasoning_kernel.kernel.effects import EffectBlocked, EffectDispatcher
|
|
19
|
+
from reasoning_kernel.kernel.taint import join_labels, quarantine_label
|
|
20
|
+
from reasoning_kernel.memory.store import ValueStore
|
|
21
|
+
from reasoning_kernel.memory.trace import TraceWriter
|
|
22
|
+
from reasoning_kernel.reasoner.base import ReasonerError
|
|
23
|
+
from reasoning_kernel.reasoner.roles import PLLM, QLLM
|
|
24
|
+
from reasoning_kernel.schemas.capability import Capability, CapabilitySet
|
|
25
|
+
from reasoning_kernel.schemas.ids import RunId
|
|
26
|
+
from reasoning_kernel.schemas.limits import RunLimits
|
|
27
|
+
from reasoning_kernel.schemas.plan import (
|
|
28
|
+
ConstStep,
|
|
29
|
+
MergeStep,
|
|
30
|
+
PlanStep,
|
|
31
|
+
QuarantineParseStep,
|
|
32
|
+
SubKernelStep,
|
|
33
|
+
ToolCallStep,
|
|
34
|
+
)
|
|
35
|
+
from reasoning_kernel.schemas.policy import RunContext, TrustedQuery
|
|
36
|
+
from reasoning_kernel.schemas.trace import (
|
|
37
|
+
PlanEmitted,
|
|
38
|
+
PlanRejected,
|
|
39
|
+
QParseResult,
|
|
40
|
+
RunAborted,
|
|
41
|
+
RunBlocked,
|
|
42
|
+
RunCommitted,
|
|
43
|
+
RunErrored,
|
|
44
|
+
RunResult,
|
|
45
|
+
StepStarted,
|
|
46
|
+
digest,
|
|
47
|
+
)
|
|
48
|
+
from reasoning_kernel.schemas.values import TaintedValue
|
|
49
|
+
|
|
50
|
+
# Errors that mean "the model produced a plan the kernel cannot safely execute", or a reasoner
|
|
51
|
+
# failed to return usable output. They are failed closed (recorded, no effect committed), not
|
|
52
|
+
# crashes. Unexpected errors still propagate.
|
|
53
|
+
_PLAN_ERRORS = (ValidationError, ValueError, KeyError, ReasonerError)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _RunAborted(Exception):
|
|
57
|
+
"""A run bound (RunLimits) was exceeded. Fails closed, like EffectBlocked."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, reason: str) -> None:
|
|
60
|
+
super().__init__(reason)
|
|
61
|
+
self.reason = reason
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Interpreter:
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
planner: PLLM,
|
|
69
|
+
quarantine: QLLM,
|
|
70
|
+
dispatcher: EffectDispatcher,
|
|
71
|
+
trace: TraceWriter,
|
|
72
|
+
q_schemas: dict[str, type[BaseModel]],
|
|
73
|
+
limits: RunLimits = RunLimits(),
|
|
74
|
+
depth: int = 0,
|
|
75
|
+
) -> None:
|
|
76
|
+
# A reasoner may never plan beyond the kernel's authority (the §5.4 composition invariant).
|
|
77
|
+
if not planner.grant.is_subset_of(dispatcher.grant()):
|
|
78
|
+
raise ValueError("planner grant exceeds the dispatcher's capability grant")
|
|
79
|
+
self._planner = planner
|
|
80
|
+
self._quarantine = quarantine
|
|
81
|
+
self._dispatcher = dispatcher
|
|
82
|
+
self._trace = trace
|
|
83
|
+
self._q_schemas = q_schemas
|
|
84
|
+
self._limits = limits
|
|
85
|
+
self._depth = depth
|
|
86
|
+
self._store = ValueStore() # replaced per run() with the query's label
|
|
87
|
+
self._effects = 0
|
|
88
|
+
self._q_parses = 0
|
|
89
|
+
|
|
90
|
+
def run(self, ctx: RunContext) -> RunResult:
|
|
91
|
+
# Per-run state — the store is labelled with the run's (trusted) query.
|
|
92
|
+
self._store = ValueStore(ctx.query.label)
|
|
93
|
+
self._effects = 0
|
|
94
|
+
self._q_parses = 0
|
|
95
|
+
prompt = build_planner_context(ctx.query.text, self._dispatcher.catalog(), self._q_schemas)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
plan = self._call_reasoner(lambda: self._planner.plan(prompt, run_id=ctx.run_id))
|
|
99
|
+
except _PLAN_ERRORS as exc:
|
|
100
|
+
self._trace.emit(PlanRejected(run_id=ctx.run_id, reason=str(exc)))
|
|
101
|
+
return self._closed()
|
|
102
|
+
except _RunAborted as ab:
|
|
103
|
+
self._trace.emit(RunAborted(run_id=ctx.run_id, reason=ab.reason))
|
|
104
|
+
return self._closed()
|
|
105
|
+
self._trace.emit(PlanEmitted(run_id=ctx.run_id, plan=plan))
|
|
106
|
+
|
|
107
|
+
if self._limits.max_steps is not None and len(plan.steps) > self._limits.max_steps:
|
|
108
|
+
self._trace.emit(
|
|
109
|
+
RunAborted(
|
|
110
|
+
run_id=ctx.run_id,
|
|
111
|
+
reason=f"plan has {len(plan.steps)} steps > max_steps {self._limits.max_steps}",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return self._closed()
|
|
115
|
+
|
|
116
|
+
for step in plan.steps:
|
|
117
|
+
self._trace.emit(StepStarted(run_id=ctx.run_id, step=step))
|
|
118
|
+
try:
|
|
119
|
+
value = self._eval_step(step, ctx)
|
|
120
|
+
except EffectBlocked:
|
|
121
|
+
self._trace.emit(RunBlocked(run_id=ctx.run_id, tool=_tool_name(step)))
|
|
122
|
+
return self._closed()
|
|
123
|
+
except _RunAborted as ab:
|
|
124
|
+
self._trace.emit(RunAborted(run_id=ctx.run_id, step_id=step.id, reason=ab.reason))
|
|
125
|
+
return self._closed()
|
|
126
|
+
except _PLAN_ERRORS as exc:
|
|
127
|
+
self._trace.emit(RunErrored(run_id=ctx.run_id, step_id=step.id, reason=str(exc)))
|
|
128
|
+
return self._closed()
|
|
129
|
+
self._store.put(step.id, value)
|
|
130
|
+
|
|
131
|
+
final = self._store.get(plan.final)
|
|
132
|
+
self._trace.emit(RunCommitted(run_id=ctx.run_id, final_digest=digest(final.value)))
|
|
133
|
+
return RunResult(trace=self._trace.snapshot(), committed=final)
|
|
134
|
+
|
|
135
|
+
def _closed(self) -> RunResult:
|
|
136
|
+
"""A fail-closed outcome: the trace so far, with nothing committed."""
|
|
137
|
+
return RunResult(trace=self._trace.snapshot(), committed=None)
|
|
138
|
+
|
|
139
|
+
def _eval_step(self, step: PlanStep, ctx: RunContext) -> TaintedValue:
|
|
140
|
+
if isinstance(step, ConstStep):
|
|
141
|
+
# A planner literal inherits the (trusted) query's label — not hardcoded trust.
|
|
142
|
+
return TaintedValue(value=step.value, label=ctx.query.label, produced_by=step.id)
|
|
143
|
+
if isinstance(step, QuarantineParseStep):
|
|
144
|
+
self._q_parses += 1
|
|
145
|
+
if self._limits.max_q_parses is not None and self._q_parses > self._limits.max_q_parses:
|
|
146
|
+
raise _RunAborted(f"q_parse count exceeds max_q_parses {self._limits.max_q_parses}")
|
|
147
|
+
src = self._store.resolve(step.source)
|
|
148
|
+
if step.schema_ref not in self._q_schemas:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"unknown q_parse schema_ref {step.schema_ref!r} "
|
|
151
|
+
f"(available: {', '.join(self._q_schemas)})"
|
|
152
|
+
)
|
|
153
|
+
schema = self._q_schemas[step.schema_ref]
|
|
154
|
+
q_prompt = build_quarantine_context(str(src.value), step.instruction)
|
|
155
|
+
parsed = self._call_reasoner(
|
|
156
|
+
lambda: self._quarantine.parse_blob(prompt=q_prompt, schema=schema)
|
|
157
|
+
)
|
|
158
|
+
label = quarantine_label(src.label)
|
|
159
|
+
self._trace.emit(QParseResult(run_id=ctx.run_id, step_id=step.id, label=label))
|
|
160
|
+
return TaintedValue(value=parsed, label=label, produced_by=step.id)
|
|
161
|
+
if isinstance(step, ToolCallStep):
|
|
162
|
+
self._effects += 1
|
|
163
|
+
if self._limits.max_effects is not None and self._effects > self._limits.max_effects:
|
|
164
|
+
raise _RunAborted(f"effect count exceeds max_effects {self._limits.max_effects}")
|
|
165
|
+
named = {k: self._store.resolve(a) for k, a in step.args.items()}
|
|
166
|
+
return self._dispatcher.dispatch(step.tool, named)
|
|
167
|
+
if isinstance(step, MergeStep):
|
|
168
|
+
return self._eval_merge(step, ctx)
|
|
169
|
+
# Only SubKernelStep remains. This is exhaustive over PlanStep: the param type makes pyright
|
|
170
|
+
# error here if a new step kind is added to the union but not handled above.
|
|
171
|
+
return self._eval_subkernel(step, ctx)
|
|
172
|
+
|
|
173
|
+
def _eval_merge(self, step: MergeStep, ctx: RunContext) -> TaintedValue:
|
|
174
|
+
# Combine the named inputs into one dict, labelled with the join of their labels: taint only
|
|
175
|
+
# ever increases (sources union + DERIVED, readers intersected, subjects union). The
|
|
176
|
+
# object-level over-approximation is sound — strictly safer than per-field labels.
|
|
177
|
+
resolved = {k: self._store.resolve(ref) for k, ref in step.inputs.items()}
|
|
178
|
+
merged: dict[str, object] = {k: tv.value for k, tv in resolved.items()}
|
|
179
|
+
label = join_labels([tv.label for tv in resolved.values()])
|
|
180
|
+
return TaintedValue(value=merged, label=label, produced_by=step.id)
|
|
181
|
+
|
|
182
|
+
def _eval_subkernel(self, step: SubKernelStep, ctx: RunContext) -> TaintedValue:
|
|
183
|
+
if self._limits.max_depth is not None and self._depth + 1 > self._limits.max_depth:
|
|
184
|
+
raise _RunAborted(f"sub-kernel depth exceeds max_depth {self._limits.max_depth}")
|
|
185
|
+
src = self._store.resolve(step.source)
|
|
186
|
+
|
|
187
|
+
# Clamp the requested grant to the outer authority — a sub-kernel can never widen it.
|
|
188
|
+
requested = frozenset(Capability(name=n) for n in step.grant)
|
|
189
|
+
inner_grant = CapabilitySet(granted=requested & self._dispatcher.grant().granted)
|
|
190
|
+
|
|
191
|
+
# The sub-planner's query is the (trusted) instruction plus the UNTRUSTED blob, labelled by
|
|
192
|
+
# the blob's label, so every literal it produces inherits that taint (Invariant A).
|
|
193
|
+
sub_text = f"{step.instruction}\n\n--- untrusted content ---\n{src.value}"
|
|
194
|
+
sub_ctx = RunContext(
|
|
195
|
+
run_id=RunId(f"{ctx.run_id}/{step.id}"),
|
|
196
|
+
user=ctx.user,
|
|
197
|
+
query=TrustedQuery(text=sub_text, label=src.label),
|
|
198
|
+
)
|
|
199
|
+
sub = Interpreter(
|
|
200
|
+
planner=self._planner.for_grant(inner_grant),
|
|
201
|
+
quarantine=self._quarantine,
|
|
202
|
+
dispatcher=self._dispatcher.for_subkernel(inner_grant, sub_ctx),
|
|
203
|
+
trace=self._trace, # shared: sub events interleave under the suffixed run_id
|
|
204
|
+
q_schemas=self._q_schemas,
|
|
205
|
+
limits=self._limits,
|
|
206
|
+
depth=self._depth + 1,
|
|
207
|
+
)
|
|
208
|
+
result = sub.run(sub_ctx)
|
|
209
|
+
if result.committed is None:
|
|
210
|
+
# The sub-kernel committed nothing (blocked/aborted/errored): the task failed closed.
|
|
211
|
+
raise ValueError(f"sub-kernel {step.id} did not commit")
|
|
212
|
+
# The outer value must dominate everything the sub touched: join source + sub-final labels.
|
|
213
|
+
label = join_labels([quarantine_label(src.label), result.committed.label])
|
|
214
|
+
return TaintedValue(value=result.committed.value, label=label, produced_by=step.id)
|
|
215
|
+
|
|
216
|
+
def _call_reasoner[T](self, thunk: Callable[[], T]) -> T:
|
|
217
|
+
"""Invoke a reasoner, enforcing the optional per-call timeout (deterministic when unset).
|
|
218
|
+
|
|
219
|
+
On timeout the run aborts *immediately*: we must not use the executor as a context manager,
|
|
220
|
+
because its ``__exit__`` calls ``shutdown(wait=True)`` and would block on the very call we
|
|
221
|
+
timed out. We shut down without waiting; Python cannot kill the orphan thread, so the bound
|
|
222
|
+
is on the run, not on the underlying call (which still relies on the provider's timeout).
|
|
223
|
+
"""
|
|
224
|
+
timeout = self._limits.reasoner_timeout_s
|
|
225
|
+
if timeout is None:
|
|
226
|
+
return thunk()
|
|
227
|
+
pool = futures.ThreadPoolExecutor(max_workers=1)
|
|
228
|
+
future = pool.submit(thunk)
|
|
229
|
+
try:
|
|
230
|
+
return future.result(timeout=timeout)
|
|
231
|
+
except futures.TimeoutError:
|
|
232
|
+
raise _RunAborted(f"reasoner call exceeded {timeout}s") from None
|
|
233
|
+
finally:
|
|
234
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _tool_name(step: PlanStep) -> str:
|
|
238
|
+
return step.tool if isinstance(step, ToolCallStep) else "<none>"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Provenance propagation — how taint flows and narrows through the interpreter.
|
|
2
|
+
|
|
3
|
+
Rules (paper section 6.1):
|
|
4
|
+
- join: ``sources = union(sources_i)`` (add ``DERIVED`` when combining >1 input);
|
|
5
|
+
``readers = intersection(readers_i)`` with ``None`` (unrestricted) as the identity element.
|
|
6
|
+
- a READ tool's output is freshly untrusted: add ``TOOL_READ`` and narrow ``readers`` to the
|
|
7
|
+
tool's declared ``result_readers``.
|
|
8
|
+
- a Q-LLM parse cannot launder taint: it keeps the source blob's label and adds ``Q_LLM``.
|
|
9
|
+
|
|
10
|
+
Restrictiveness only ever increases as untrusted data flows.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
from reasoning_kernel.schemas.capability import Capability, EffectLevel
|
|
18
|
+
from reasoning_kernel.schemas.provenance import DataSubject, ProvenanceLabel, Source
|
|
19
|
+
from reasoning_kernel.schemas.registry import ToolSpec
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def join_labels(labels: Sequence[ProvenanceLabel]) -> ProvenanceLabel:
|
|
23
|
+
if not labels:
|
|
24
|
+
return ProvenanceLabel.trusted()
|
|
25
|
+
sources: set[Source] = set()
|
|
26
|
+
subjects: set[DataSubject] = set()
|
|
27
|
+
for label in labels:
|
|
28
|
+
sources |= label.sources
|
|
29
|
+
subjects |= label.subjects
|
|
30
|
+
if len(labels) > 1:
|
|
31
|
+
sources.add(Source.DERIVED)
|
|
32
|
+
|
|
33
|
+
readers: frozenset[Capability] | None = None
|
|
34
|
+
for label in labels:
|
|
35
|
+
if label.readers is None:
|
|
36
|
+
continue
|
|
37
|
+
readers = label.readers if readers is None else (readers & label.readers)
|
|
38
|
+
return ProvenanceLabel(
|
|
39
|
+
sources=frozenset(sources), readers=readers, subjects=frozenset(subjects)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def result_label(spec: ToolSpec, arg_labels: Sequence[ProvenanceLabel]) -> ProvenanceLabel:
|
|
44
|
+
"""Provenance of a tool's output, given its argument labels."""
|
|
45
|
+
base = join_labels(arg_labels)
|
|
46
|
+
sources = set(base.sources)
|
|
47
|
+
readers = base.readers
|
|
48
|
+
subjects = base.subjects | spec.result_subjects # subjects only ever accumulate
|
|
49
|
+
if spec.effect_level == EffectLevel.READ:
|
|
50
|
+
sources.add(Source.TOOL_READ)
|
|
51
|
+
rr = spec.result_readers
|
|
52
|
+
readers = rr if readers is None else (readers & rr)
|
|
53
|
+
return ProvenanceLabel(
|
|
54
|
+
sources=frozenset(sources), readers=readers, subjects=frozenset(subjects)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def quarantine_label(source_label: ProvenanceLabel) -> ProvenanceLabel:
|
|
59
|
+
"""The label of a Q-LLM parse result: source taint and subjects preserved, ``Q_LLM`` added.
|
|
60
|
+
|
|
61
|
+
The Q-LLM cannot launder ``subjects`` any more than it can launder ``sources``: a summary of
|
|
62
|
+
third-party data is still third-party data.
|
|
63
|
+
"""
|
|
64
|
+
return ProvenanceLabel(
|
|
65
|
+
sources=frozenset(source_label.sources | {Source.Q_LLM}),
|
|
66
|
+
readers=source_label.readers,
|
|
67
|
+
subjects=source_label.subjects,
|
|
68
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The value store — the interpreter's only place to keep step results.
|
|
2
|
+
|
|
3
|
+
Holds ``StepId -> TaintedValue``, and resolves a plan ``ArgValue`` (a reference or an inline
|
|
4
|
+
literal) into a ``TaintedValue``. Inline literals are labelled trusted: they come from the plan,
|
|
5
|
+
which the planner produced having seen only the controlled query (Invariant A).
|
|
6
|
+
|
|
7
|
+
Limit: taint is object-level. Navigating a ``path`` keeps the whole value's label. The
|
|
8
|
+
value-combining step (``MergeStep``) labels its result with the *join* of its inputs, so a
|
|
9
|
+
composite of differing provenances carries one label that over-approximates them all — safer than
|
|
10
|
+
per-field labels. Field-level labels (recovering a trusted field from a mixed structure without
|
|
11
|
+
over-tainting it) stay deferred: they buy precision, not soundness, until a use case needs them.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import cast
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from reasoning_kernel.schemas.ids import StepId
|
|
21
|
+
from reasoning_kernel.schemas.plan import ArgRef, ArgValue
|
|
22
|
+
from reasoning_kernel.schemas.provenance import ProvenanceLabel
|
|
23
|
+
from reasoning_kernel.schemas.values import TaintedValue
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ValueStore:
|
|
27
|
+
def __init__(self, query_label: ProvenanceLabel | None = None) -> None:
|
|
28
|
+
self._values: dict[StepId, TaintedValue] = {}
|
|
29
|
+
# Inline literals derive their label from the run's (trusted) query — see resolve().
|
|
30
|
+
self._query_label = query_label if query_label is not None else ProvenanceLabel.trusted()
|
|
31
|
+
|
|
32
|
+
def put(self, step_id: StepId, value: TaintedValue) -> None:
|
|
33
|
+
if step_id in self._values:
|
|
34
|
+
raise ValueError(f"step result already stored: {step_id!r}")
|
|
35
|
+
self._values[step_id] = value
|
|
36
|
+
|
|
37
|
+
def get(self, step_id: StepId) -> TaintedValue:
|
|
38
|
+
return self._values[step_id]
|
|
39
|
+
|
|
40
|
+
def resolve(self, arg: ArgValue) -> TaintedValue:
|
|
41
|
+
if isinstance(arg, ArgRef):
|
|
42
|
+
tv = self._values[arg.ref]
|
|
43
|
+
if arg.path is None:
|
|
44
|
+
return tv
|
|
45
|
+
return TaintedValue(
|
|
46
|
+
value=_navigate(tv.value, arg.path), label=tv.label, produced_by=tv.produced_by
|
|
47
|
+
)
|
|
48
|
+
# Inline literal: derives from the run's query label (trusted unless the query is not).
|
|
49
|
+
return TaintedValue(value=arg, label=self._query_label, produced_by=StepId("__literal__"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _navigate(value: object, path: str) -> object:
|
|
53
|
+
cur: object = value
|
|
54
|
+
for part in path.split("."):
|
|
55
|
+
if isinstance(cur, BaseModel):
|
|
56
|
+
if part not in type(cur).model_fields:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"path {path!r}: {type(cur).__name__} has no field {part!r} "
|
|
59
|
+
f"(available: {', '.join(type(cur).model_fields)})"
|
|
60
|
+
)
|
|
61
|
+
cur = getattr(cur, part)
|
|
62
|
+
elif isinstance(cur, dict):
|
|
63
|
+
# The kernel treats payloads as opaque: dict values are navigated as plain ``object``.
|
|
64
|
+
cur_dict = cast("dict[str, object]", cur)
|
|
65
|
+
if part not in cur_dict:
|
|
66
|
+
raise ValueError(f"path {path!r}: key {part!r} not in dict")
|
|
67
|
+
cur = cur_dict[part]
|
|
68
|
+
else:
|
|
69
|
+
raise ValueError(f"path {path!r}: {type(cur).__name__} has no {part!r}")
|
|
70
|
+
return cur
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The trace writer — an append-only sink for the auditable record.
|
|
2
|
+
|
|
3
|
+
Exposes only ``emit`` (append, assigning a monotonic ``seq``) and ``snapshot`` (read). There is
|
|
4
|
+
no mutate or delete: the record can grow and be read, never rewritten.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from reasoning_kernel.schemas.ids import RunId
|
|
10
|
+
from reasoning_kernel.schemas.trace import RunTrace, TraceEvent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TraceWriter:
|
|
14
|
+
def __init__(self, run_id: RunId) -> None:
|
|
15
|
+
self.run_id = run_id
|
|
16
|
+
self._events: list[TraceEvent] = []
|
|
17
|
+
|
|
18
|
+
def emit(self, event: TraceEvent) -> None:
|
|
19
|
+
event.seq = len(self._events)
|
|
20
|
+
self._events.append(event)
|
|
21
|
+
|
|
22
|
+
def snapshot(self) -> RunTrace:
|
|
23
|
+
return RunTrace(run_id=self.run_id, events=list(self._events))
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Anthropic-backed provider (structured outputs + ephemeral prompt cache).
|
|
2
|
+
|
|
3
|
+
Mirrors limolane's ``infra/llm/anthropic.py``. Imported lazily so the package works without
|
|
4
|
+
the ``anthropic`` SDK installed (the default test suite uses the FakeProvider).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from reasoning_kernel.reasoner.base import LLMResult, LLMUsage, ReasonerError
|
|
14
|
+
|
|
15
|
+
_BETA = "structured-outputs-2025-11-13"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AnthropicProvider:
|
|
19
|
+
name = "anthropic"
|
|
20
|
+
supports_prompt_cache = True
|
|
21
|
+
supports_structured_output = True
|
|
22
|
+
|
|
23
|
+
def __init__(self, client: Any | None = None) -> None:
|
|
24
|
+
self._client = client # injection seam for tests
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def client(self) -> Any:
|
|
28
|
+
if self._client is None:
|
|
29
|
+
import anthropic
|
|
30
|
+
|
|
31
|
+
from reasoning_kernel.config import settings
|
|
32
|
+
|
|
33
|
+
self._client = anthropic.Anthropic(
|
|
34
|
+
api_key=settings.anthropic_api_key.get_secret_value() or None,
|
|
35
|
+
timeout=settings.llm_timeout_seconds,
|
|
36
|
+
default_headers={"anthropic-beta": _BETA},
|
|
37
|
+
)
|
|
38
|
+
return self._client
|
|
39
|
+
|
|
40
|
+
def parse[T: BaseModel](
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
prompt: str,
|
|
44
|
+
schema: type[T],
|
|
45
|
+
system: str | None,
|
|
46
|
+
model: str,
|
|
47
|
+
max_tokens: int,
|
|
48
|
+
cache_system: bool = True,
|
|
49
|
+
) -> LLMResult[T]:
|
|
50
|
+
kwargs: dict[str, Any] = {
|
|
51
|
+
"model": model,
|
|
52
|
+
"max_tokens": max_tokens,
|
|
53
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
54
|
+
"output_format": schema,
|
|
55
|
+
}
|
|
56
|
+
if system:
|
|
57
|
+
block: dict[str, Any] = {"type": "text", "text": system}
|
|
58
|
+
if cache_system:
|
|
59
|
+
block["cache_control"] = {"type": "ephemeral"}
|
|
60
|
+
kwargs["system"] = [block]
|
|
61
|
+
|
|
62
|
+
response = self.client.messages.parse(**kwargs)
|
|
63
|
+
parsed = response.parsed_output
|
|
64
|
+
if parsed is None:
|
|
65
|
+
raise ReasonerError(f"Anthropic returned no parsed output for {schema.__name__}")
|
|
66
|
+
u = response.usage
|
|
67
|
+
usage = LLMUsage(
|
|
68
|
+
input_tokens=getattr(u, "input_tokens", 0),
|
|
69
|
+
output_tokens=getattr(u, "output_tokens", 0),
|
|
70
|
+
cache_read_tokens=getattr(u, "cache_read_input_tokens", 0),
|
|
71
|
+
)
|
|
72
|
+
return LLMResult(
|
|
73
|
+
data=parsed,
|
|
74
|
+
usage=usage,
|
|
75
|
+
model=getattr(response, "model", model),
|
|
76
|
+
provider=self.name,
|
|
77
|
+
raw=response,
|
|
78
|
+
)
|