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.
Files changed (48) hide show
  1. capability_reasoning_kernel-0.4.1.dist-info/METADATA +256 -0
  2. capability_reasoning_kernel-0.4.1.dist-info/RECORD +48 -0
  3. capability_reasoning_kernel-0.4.1.dist-info/WHEEL +4 -0
  4. capability_reasoning_kernel-0.4.1.dist-info/entry_points.txt +2 -0
  5. capability_reasoning_kernel-0.4.1.dist-info/licenses/LICENSE +21 -0
  6. reasoning_kernel/__init__.py +80 -0
  7. reasoning_kernel/config.py +48 -0
  8. reasoning_kernel/context/__init__.py +0 -0
  9. reasoning_kernel/context/assembler.py +66 -0
  10. reasoning_kernel/demo/__init__.py +0 -0
  11. reasoning_kernel/demo/_report.py +32 -0
  12. reasoning_kernel/demo/email_exfil.py +203 -0
  13. reasoning_kernel/demo/live_run.py +86 -0
  14. reasoning_kernel/demo/merge.py +86 -0
  15. reasoning_kernel/demo/reasoner_error.py +94 -0
  16. reasoning_kernel/demo/run_limits.py +48 -0
  17. reasoning_kernel/demo/subkernel.py +135 -0
  18. reasoning_kernel/kernel/__init__.py +0 -0
  19. reasoning_kernel/kernel/effects.py +90 -0
  20. reasoning_kernel/kernel/gate.py +88 -0
  21. reasoning_kernel/kernel/interpreter.py +238 -0
  22. reasoning_kernel/kernel/taint.py +68 -0
  23. reasoning_kernel/memory/__init__.py +0 -0
  24. reasoning_kernel/memory/store.py +70 -0
  25. reasoning_kernel/memory/trace.py +23 -0
  26. reasoning_kernel/py.typed +0 -0
  27. reasoning_kernel/reasoner/__init__.py +0 -0
  28. reasoning_kernel/reasoner/anthropic.py +78 -0
  29. reasoning_kernel/reasoner/base.py +58 -0
  30. reasoning_kernel/reasoner/deepseek.py +26 -0
  31. reasoning_kernel/reasoner/factory.py +39 -0
  32. reasoning_kernel/reasoner/fake.py +56 -0
  33. reasoning_kernel/reasoner/openai.py +126 -0
  34. reasoning_kernel/reasoner/parse.py +52 -0
  35. reasoning_kernel/reasoner/roles.py +92 -0
  36. reasoning_kernel/schemas/__init__.py +0 -0
  37. reasoning_kernel/schemas/capability.py +50 -0
  38. reasoning_kernel/schemas/ids.py +8 -0
  39. reasoning_kernel/schemas/limits.py +23 -0
  40. reasoning_kernel/schemas/plan.py +143 -0
  41. reasoning_kernel/schemas/policy.py +64 -0
  42. reasoning_kernel/schemas/provenance.py +63 -0
  43. reasoning_kernel/schemas/registry.py +41 -0
  44. reasoning_kernel/schemas/trace.py +110 -0
  45. reasoning_kernel/schemas/values.py +28 -0
  46. reasoning_kernel/tools/__init__.py +0 -0
  47. reasoning_kernel/tools/demo_mail.py +213 -0
  48. 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
+ )