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,64 @@
|
|
|
1
|
+
"""The Verifier's vocabulary: a run's trusted context, a verdict, and the declassify hook.
|
|
2
|
+
|
|
3
|
+
``DeclassPolicy`` is the single, auditable seam where tainted data is allowed to cross into a
|
|
4
|
+
WRITE effect. It is a deterministic predicate — never an LLM — and (per the paper) it should
|
|
5
|
+
compare against *trusted* (USER_QUERY) values only, never against substrings of tainted text.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
from reasoning_kernel.schemas.ids import RunId
|
|
15
|
+
from reasoning_kernel.schemas.provenance import ProvenanceLabel
|
|
16
|
+
from reasoning_kernel.schemas.registry import ToolSpec
|
|
17
|
+
from reasoning_kernel.schemas.values import TaintedValue
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TrustedQuery(BaseModel):
|
|
21
|
+
"""The controlled user query as an explicit trusted channel (Invariant A).
|
|
22
|
+
|
|
23
|
+
Carrying the label (not a bare ``str``/``NewType``) makes the trust assumption explicit and lets
|
|
24
|
+
planner-supplied literals DERIVE their provenance from the query: if the query were ever not
|
|
25
|
+
trusted, every ``const`` would inherit that taint automatically.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(frozen=True)
|
|
29
|
+
|
|
30
|
+
text: str
|
|
31
|
+
label: ProvenanceLabel = ProvenanceLabel.trusted()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RunContext(BaseModel):
|
|
35
|
+
"""Trusted, controlled facts about a run — available to the Verifier and declassifier."""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(frozen=True)
|
|
38
|
+
|
|
39
|
+
run_id: RunId
|
|
40
|
+
user: str # the trusted requesting identity (e.g. the user's own email)
|
|
41
|
+
query: TrustedQuery # the controlled user query (the only thing the P-LLM ever sees)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class VerifierVerdict(BaseModel):
|
|
45
|
+
"""The outcome of a verification check."""
|
|
46
|
+
|
|
47
|
+
allowed: bool
|
|
48
|
+
reason: str
|
|
49
|
+
issues: list[str] = Field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DeclassPolicy(Protocol):
|
|
53
|
+
"""Decides whether tainted arguments may flow into a WRITE effect.
|
|
54
|
+
|
|
55
|
+
Implementations MUST be deterministic and free of LLM calls: the kernel invokes this on
|
|
56
|
+
the commit path and cannot enforce that contract through the type system.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def may_declassify(
|
|
60
|
+
self,
|
|
61
|
+
tool: ToolSpec,
|
|
62
|
+
named_args: dict[str, TaintedValue],
|
|
63
|
+
ctx: RunContext,
|
|
64
|
+
) -> VerifierVerdict: ...
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Provenance labels — the taint a value carries through the interpreter.
|
|
2
|
+
|
|
3
|
+
This is the §6.1 mechanism: the kernel does not *sanitize* untrusted text, it *scopes the
|
|
4
|
+
capabilities* that untrusted-derived data is permitted to flow into. A label records where
|
|
5
|
+
a value came from (``sources``) and which capabilities it may flow into (``readers``).
|
|
6
|
+
``readers=None`` means unrestricted, reserved for purely trusted data.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
|
|
15
|
+
from reasoning_kernel.schemas.capability import Capability
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Source(StrEnum):
|
|
19
|
+
"""Origin of a value. ``USER_QUERY`` is trusted; the rest taint."""
|
|
20
|
+
|
|
21
|
+
USER_QUERY = "user_query" # the controlled query and planner literals (trusted)
|
|
22
|
+
TOOL_READ = "tool_read" # anything a READ tool returned (untrusted)
|
|
23
|
+
Q_LLM = "q_llm" # anything the quarantined reasoner produced (untrusted)
|
|
24
|
+
DERIVED = "derived" # combined from >1 input
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DataSubject(StrEnum):
|
|
28
|
+
"""Whose data a value is about — orthogonal to trust, used for declassification scoping."""
|
|
29
|
+
|
|
30
|
+
USER = "user" # the requesting user's own data
|
|
31
|
+
THIRD_PARTY = "third_party" # anyone else (contacts, other inboxes, ...)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_UNTRUSTED: frozenset[Source] = frozenset({Source.TOOL_READ, Source.Q_LLM, Source.DERIVED})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProvenanceLabel(BaseModel):
|
|
38
|
+
"""Immutable provenance label: where a value came from, where it may flow, who it is about."""
|
|
39
|
+
|
|
40
|
+
model_config = ConfigDict(frozen=True)
|
|
41
|
+
|
|
42
|
+
sources: frozenset[Source]
|
|
43
|
+
readers: frozenset[Capability] | None = None # None = unrestricted (trusted only)
|
|
44
|
+
subjects: frozenset[DataSubject] = frozenset() # empty = no third-party content
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_tainted(self) -> bool:
|
|
48
|
+
return bool(self.sources & _UNTRUSTED)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def has_third_party(self) -> bool:
|
|
52
|
+
return DataSubject.THIRD_PARTY in self.subjects
|
|
53
|
+
|
|
54
|
+
def allows_reader(self, cap: Capability) -> bool:
|
|
55
|
+
"""True if a value with this label may flow into an effect requiring ``cap``."""
|
|
56
|
+
if self.readers is None:
|
|
57
|
+
return True
|
|
58
|
+
return cap in self.readers
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def trusted(cls) -> ProvenanceLabel:
|
|
62
|
+
"""A label for data derived solely from the controlled user query."""
|
|
63
|
+
return cls(sources=frozenset({Source.USER_QUERY}), readers=None, subjects=frozenset())
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Tool specifications — the declared contract the Verifier checks a call against.
|
|
2
|
+
|
|
3
|
+
A ``ToolSpec`` is pure data: name, typed input/output, the capabilities the call requires,
|
|
4
|
+
its effect level, and ``result_readers`` — the capabilities that data RETURNED by the tool
|
|
5
|
+
is allowed to flow into. A READ tool that surfaces untrusted content sets
|
|
6
|
+
``result_readers=frozenset()`` so its output may flow into no WRITE.
|
|
7
|
+
|
|
8
|
+
The callable itself is held elsewhere (``tools.registry.RegisteredTool``), never in the schema
|
|
9
|
+
layer — so the contract can be inspected and reasoned about without holding the effect.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, model_validator
|
|
15
|
+
|
|
16
|
+
from reasoning_kernel.schemas.capability import Capability, EffectLevel
|
|
17
|
+
from reasoning_kernel.schemas.provenance import DataSubject
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ToolSpec(BaseModel):
|
|
21
|
+
"""Declared, inspectable contract for one tool. Immutable."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
input_schema: type[BaseModel]
|
|
27
|
+
output_schema: type[BaseModel]
|
|
28
|
+
required_caps: frozenset[Capability]
|
|
29
|
+
effect_level: EffectLevel
|
|
30
|
+
result_readers: frozenset[Capability] = frozenset()
|
|
31
|
+
result_subjects: frozenset[DataSubject] = frozenset() # whose data this tool's output is about
|
|
32
|
+
|
|
33
|
+
@model_validator(mode="after")
|
|
34
|
+
def _write_must_declare_capability(self) -> ToolSpec:
|
|
35
|
+
# A world-mutating effect must be gated by at least one capability; otherwise the
|
|
36
|
+
# provenance check would have no capability to reason about (see kernel/gate.py).
|
|
37
|
+
if self.effect_level >= EffectLevel.WRITE and not self.required_caps:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"WRITE tool {self.name!r} must declare at least one required capability"
|
|
40
|
+
)
|
|
41
|
+
return self
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The auditable record (Memory/Trace role): an append-only log of everything that happened.
|
|
2
|
+
|
|
3
|
+
Every plan, step, gate decision, commit, and block becomes a ``TraceEvent``. The trace is the
|
|
4
|
+
home the paper gives to auditability: a wrong or blocked decision leaves a record of what was
|
|
5
|
+
decided and why. Events carry a monotonic ``seq`` (assigned by the writer) rather than a wall
|
|
6
|
+
clock, so traces are deterministic and comparable in tests.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
|
|
15
|
+
from reasoning_kernel.schemas.ids import RunId, StepId
|
|
16
|
+
from reasoning_kernel.schemas.plan import Plan, PlanStep
|
|
17
|
+
from reasoning_kernel.schemas.policy import VerifierVerdict
|
|
18
|
+
from reasoning_kernel.schemas.provenance import ProvenanceLabel
|
|
19
|
+
from reasoning_kernel.schemas.values import TaintedValue
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def digest(value: object) -> str:
|
|
23
|
+
"""A short, stable content digest for trace records (not a security primitive)."""
|
|
24
|
+
return hashlib.sha256(repr(value).encode("utf-8")).hexdigest()[:12]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TraceEvent(BaseModel):
|
|
28
|
+
"""Base event. ``kind`` discriminates; ``seq`` is assigned by the TraceWriter on emit."""
|
|
29
|
+
|
|
30
|
+
kind: str = "event"
|
|
31
|
+
run_id: RunId
|
|
32
|
+
seq: int = -1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PlanEmitted(TraceEvent):
|
|
36
|
+
kind: str = "plan_emitted"
|
|
37
|
+
plan: Plan
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StepStarted(TraceEvent):
|
|
41
|
+
kind: str = "step_started"
|
|
42
|
+
step: PlanStep
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class QParseResult(TraceEvent):
|
|
46
|
+
kind: str = "q_parse_result"
|
|
47
|
+
step_id: StepId
|
|
48
|
+
label: ProvenanceLabel
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GateDecision(TraceEvent):
|
|
52
|
+
kind: str = "gate_decision"
|
|
53
|
+
tool: str
|
|
54
|
+
verdict: VerifierVerdict
|
|
55
|
+
arg_labels: list[ProvenanceLabel]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class EffectCommitted(TraceEvent):
|
|
59
|
+
kind: str = "effect_committed"
|
|
60
|
+
tool: str
|
|
61
|
+
output_digest: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class EffectBlockedEvent(TraceEvent):
|
|
65
|
+
kind: str = "effect_blocked"
|
|
66
|
+
tool: str
|
|
67
|
+
verdict: VerifierVerdict
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RunCommitted(TraceEvent):
|
|
71
|
+
kind: str = "run_committed"
|
|
72
|
+
final_digest: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RunBlocked(TraceEvent):
|
|
76
|
+
kind: str = "run_blocked"
|
|
77
|
+
tool: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PlanRejected(TraceEvent):
|
|
81
|
+
kind: str = "plan_rejected"
|
|
82
|
+
reason: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RunErrored(TraceEvent):
|
|
86
|
+
kind: str = "run_errored"
|
|
87
|
+
step_id: StepId | None = None
|
|
88
|
+
reason: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RunAborted(TraceEvent):
|
|
92
|
+
kind: str = "run_aborted"
|
|
93
|
+
step_id: StepId | None = None
|
|
94
|
+
reason: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RunTrace(BaseModel):
|
|
98
|
+
"""An immutable snapshot of a run's event log."""
|
|
99
|
+
|
|
100
|
+
run_id: RunId
|
|
101
|
+
events: list[TraceEvent]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RunResult(BaseModel):
|
|
105
|
+
"""A run's outcome: the audit trace plus the committed final value (None if not committed)."""
|
|
106
|
+
|
|
107
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
108
|
+
|
|
109
|
+
trace: RunTrace
|
|
110
|
+
committed: TaintedValue | None = None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The wrapped value type that flows through the interpreter's value store.
|
|
2
|
+
|
|
3
|
+
Every result the interpreter produces is a ``TaintedValue``: a payload plus its provenance
|
|
4
|
+
label plus the step that produced it. Nothing reaches an effect except as a ``TaintedValue``,
|
|
5
|
+
so the Verifier always has a label to reason about.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict
|
|
11
|
+
|
|
12
|
+
from reasoning_kernel.schemas.ids import StepId
|
|
13
|
+
from reasoning_kernel.schemas.provenance import ProvenanceLabel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TaintedValue(BaseModel):
|
|
17
|
+
"""A value carrying its provenance. Frozen; ``value`` is opaque to the kernel.
|
|
18
|
+
|
|
19
|
+
``value`` is typed ``object`` rather than ``Any`` deliberately: the kernel never inspects the
|
|
20
|
+
payload, so the stricter type both documents that and keeps the trusted core free of ``Any``
|
|
21
|
+
leakage (the ``kernel`` and ``memory`` packages are type-checked in strict mode).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
|
25
|
+
|
|
26
|
+
value: object
|
|
27
|
+
label: ProvenanceLabel
|
|
28
|
+
produced_by: StepId
|
|
File without changes
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""An in-memory mail world + tools, for the worked demo and tests.
|
|
2
|
+
|
|
3
|
+
Three tools: ``read_inbox`` and ``read_contacts`` (READ — they introduce untrusted content, so
|
|
4
|
+
their results may flow into no WRITE), and ``send_email`` (WRITE). The declassification policy
|
|
5
|
+
allows a WRITE carrying tainted data only when the recipient is the trusted requesting user —
|
|
6
|
+
a deterministic predicate over a USER_QUERY value, never over tainted text.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from reasoning_kernel.schemas.capability import Capability, CapabilitySet, EffectLevel
|
|
16
|
+
from reasoning_kernel.schemas.policy import RunContext, VerifierVerdict
|
|
17
|
+
from reasoning_kernel.schemas.provenance import DataSubject
|
|
18
|
+
from reasoning_kernel.schemas.registry import ToolSpec
|
|
19
|
+
from reasoning_kernel.schemas.values import TaintedValue
|
|
20
|
+
from reasoning_kernel.tools.registry import ToolRegistry
|
|
21
|
+
|
|
22
|
+
# --- capabilities -----------------------------------------------------------------
|
|
23
|
+
CAP_MAIL_READ = Capability(name="mail.read")
|
|
24
|
+
CAP_CONTACTS_READ = Capability(name="contacts.read")
|
|
25
|
+
CAP_MAIL_SEND = Capability(name="mail.send")
|
|
26
|
+
CAP_CALENDAR_WRITE = Capability(name="calendar.write")
|
|
27
|
+
|
|
28
|
+
DEMO_GRANT = CapabilitySet(
|
|
29
|
+
granted=frozenset({CAP_MAIL_READ, CAP_CONTACTS_READ, CAP_MAIL_SEND, CAP_CALENDAR_WRITE})
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --- tool I/O schemas -------------------------------------------------------------
|
|
34
|
+
class EmailMessage(BaseModel):
|
|
35
|
+
sender: str
|
|
36
|
+
subject: str
|
|
37
|
+
body: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Contact(BaseModel):
|
|
41
|
+
name: str
|
|
42
|
+
email: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ReadInboxIn(BaseModel):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ReadInboxOut(BaseModel):
|
|
50
|
+
latest: EmailMessage
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ReadContactsIn(BaseModel):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ReadContactsOut(BaseModel):
|
|
58
|
+
contacts: list[Contact]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SendEmailIn(BaseModel):
|
|
62
|
+
to: str
|
|
63
|
+
body: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SendEmailOut(BaseModel):
|
|
67
|
+
ok: bool
|
|
68
|
+
message_id: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CreateEventIn(BaseModel):
|
|
72
|
+
title: str
|
|
73
|
+
date: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CreateEventOut(BaseModel):
|
|
77
|
+
ok: bool
|
|
78
|
+
event_id: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class EmailSummary(BaseModel):
|
|
82
|
+
"""Q-LLM output schema — data only, no actions."""
|
|
83
|
+
|
|
84
|
+
text: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- the mutable world ------------------------------------------------------------
|
|
88
|
+
@dataclass
|
|
89
|
+
class MailWorld:
|
|
90
|
+
inbox: list[EmailMessage]
|
|
91
|
+
contacts: list[Contact]
|
|
92
|
+
sent: list[SendEmailIn] = field(default_factory=list)
|
|
93
|
+
events: list[CreateEventIn] = field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_registry(world: MailWorld) -> ToolRegistry:
|
|
97
|
+
"""Register the demo tools against ``world``. READ tools taint their output."""
|
|
98
|
+
|
|
99
|
+
def read_inbox(_inp: BaseModel) -> BaseModel:
|
|
100
|
+
if not world.inbox:
|
|
101
|
+
raise ValueError("inbox is empty: no latest email to read")
|
|
102
|
+
return ReadInboxOut(latest=world.inbox[-1])
|
|
103
|
+
|
|
104
|
+
def read_contacts(_inp: BaseModel) -> BaseModel:
|
|
105
|
+
return ReadContactsOut(contacts=list(world.contacts))
|
|
106
|
+
|
|
107
|
+
def send_email(inp: BaseModel) -> BaseModel:
|
|
108
|
+
if not isinstance(inp, SendEmailIn):
|
|
109
|
+
raise TypeError(f"send_email expected SendEmailIn, got {type(inp).__name__}")
|
|
110
|
+
world.sent.append(inp)
|
|
111
|
+
return SendEmailOut(ok=True, message_id=f"msg-{len(world.sent)}")
|
|
112
|
+
|
|
113
|
+
def create_event(inp: BaseModel) -> BaseModel:
|
|
114
|
+
if not isinstance(inp, CreateEventIn):
|
|
115
|
+
raise TypeError(f"create_event expected CreateEventIn, got {type(inp).__name__}")
|
|
116
|
+
world.events.append(inp)
|
|
117
|
+
return CreateEventOut(ok=True, event_id=f"evt-{len(world.events)}")
|
|
118
|
+
|
|
119
|
+
registry = ToolRegistry()
|
|
120
|
+
registry.register(
|
|
121
|
+
ToolSpec(
|
|
122
|
+
name="read_inbox",
|
|
123
|
+
input_schema=ReadInboxIn,
|
|
124
|
+
output_schema=ReadInboxOut,
|
|
125
|
+
required_caps=frozenset({CAP_MAIL_READ}),
|
|
126
|
+
effect_level=EffectLevel.READ,
|
|
127
|
+
result_readers=frozenset(), # untrusted content: may flow into no WRITE
|
|
128
|
+
result_subjects=frozenset({DataSubject.USER}), # the user's own mailbox
|
|
129
|
+
),
|
|
130
|
+
read_inbox,
|
|
131
|
+
)
|
|
132
|
+
registry.register(
|
|
133
|
+
ToolSpec(
|
|
134
|
+
name="read_contacts",
|
|
135
|
+
input_schema=ReadContactsIn,
|
|
136
|
+
output_schema=ReadContactsOut,
|
|
137
|
+
required_caps=frozenset({CAP_CONTACTS_READ}),
|
|
138
|
+
effect_level=EffectLevel.READ,
|
|
139
|
+
result_readers=frozenset(),
|
|
140
|
+
result_subjects=frozenset({DataSubject.THIRD_PARTY}), # other people's data
|
|
141
|
+
),
|
|
142
|
+
read_contacts,
|
|
143
|
+
)
|
|
144
|
+
registry.register(
|
|
145
|
+
ToolSpec(
|
|
146
|
+
name="send_email",
|
|
147
|
+
input_schema=SendEmailIn,
|
|
148
|
+
output_schema=SendEmailOut,
|
|
149
|
+
required_caps=frozenset({CAP_MAIL_SEND}),
|
|
150
|
+
effect_level=EffectLevel.WRITE,
|
|
151
|
+
),
|
|
152
|
+
send_email,
|
|
153
|
+
)
|
|
154
|
+
registry.register(
|
|
155
|
+
ToolSpec(
|
|
156
|
+
name="create_event",
|
|
157
|
+
input_schema=CreateEventIn,
|
|
158
|
+
output_schema=CreateEventOut,
|
|
159
|
+
required_caps=frozenset({CAP_CALENDAR_WRITE}),
|
|
160
|
+
effect_level=EffectLevel.WRITE,
|
|
161
|
+
),
|
|
162
|
+
create_event,
|
|
163
|
+
)
|
|
164
|
+
return registry
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
Q_SCHEMAS: dict[str, type[BaseModel]] = {"EmailSummary": EmailSummary}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class RecipientIsUserPolicy:
|
|
171
|
+
"""Declassify a tainted WRITE only when the recipient is the trusted requesting user.
|
|
172
|
+
|
|
173
|
+
Note (limit): this permits self-directed sends of any tainted body — including third-party data
|
|
174
|
+
mailed to oneself. It blocks exfiltration to third parties, which is the demo's threat model; it
|
|
175
|
+
is a property of this policy, not of the pattern. A stricter policy would scope by data subject.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def may_declassify(
|
|
179
|
+
self,
|
|
180
|
+
tool: ToolSpec,
|
|
181
|
+
named_args: dict[str, TaintedValue],
|
|
182
|
+
ctx: RunContext,
|
|
183
|
+
) -> VerifierVerdict:
|
|
184
|
+
# create_event: a calendar entry derived from the user's own data is fine; third-party not.
|
|
185
|
+
if tool.name == "create_event":
|
|
186
|
+
if any(v.label.has_third_party for v in named_args.values()):
|
|
187
|
+
return VerifierVerdict(
|
|
188
|
+
allowed=False, reason="third-party data may not be written to the calendar"
|
|
189
|
+
)
|
|
190
|
+
return VerifierVerdict(allowed=True, reason="event derived from the user's own data")
|
|
191
|
+
if tool.name != "send_email":
|
|
192
|
+
return VerifierVerdict(allowed=False, reason="no declassification rule for this tool")
|
|
193
|
+
# Third-party data must not be transmitted at all — not even to the requesting user.
|
|
194
|
+
body = named_args.get("body")
|
|
195
|
+
if body is not None and body.label.has_third_party:
|
|
196
|
+
return VerifierVerdict(
|
|
197
|
+
allowed=False,
|
|
198
|
+
reason="third-party data may not be transmitted, even to the requesting user",
|
|
199
|
+
)
|
|
200
|
+
recipient = named_args.get("to")
|
|
201
|
+
if recipient is None:
|
|
202
|
+
return VerifierVerdict(allowed=False, reason="missing recipient")
|
|
203
|
+
# Compare against the trusted user only; the recipient itself must be untainted.
|
|
204
|
+
if (
|
|
205
|
+
not recipient.label.is_tainted
|
|
206
|
+
and isinstance(recipient.value, str)
|
|
207
|
+
and recipient.value == ctx.user
|
|
208
|
+
):
|
|
209
|
+
return VerifierVerdict(allowed=True, reason="recipient is the trusted requesting user")
|
|
210
|
+
return VerifierVerdict(
|
|
211
|
+
allowed=False,
|
|
212
|
+
reason="recipient is not the trusted user — tainted data may not leave",
|
|
213
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The tool registry — where the effect callables live, and nowhere else.
|
|
2
|
+
|
|
3
|
+
A ``RegisteredTool`` binds a declared ``ToolSpec`` to its callable. The registry is the only
|
|
4
|
+
holder of callables; it hands them solely to the ``EffectDispatcher``. The interpreter never
|
|
5
|
+
receives the registry, so it has no reference path to a callable — half of the by-construction
|
|
6
|
+
no-bypass guarantee (the other half is the dispatcher requiring a Gate).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from reasoning_kernel.schemas.registry import ToolSpec
|
|
17
|
+
|
|
18
|
+
ToolCallable = Callable[[BaseModel], BaseModel]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class RegisteredTool:
|
|
23
|
+
spec: ToolSpec
|
|
24
|
+
callable: ToolCallable
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ToolRegistry:
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._tools: dict[str, RegisteredTool] = {}
|
|
30
|
+
|
|
31
|
+
def register(self, spec: ToolSpec, fn: ToolCallable) -> None:
|
|
32
|
+
if spec.name in self._tools:
|
|
33
|
+
raise ValueError(f"tool already registered: {spec.name!r}")
|
|
34
|
+
self._tools[spec.name] = RegisteredTool(spec=spec, callable=fn)
|
|
35
|
+
|
|
36
|
+
def get(self, name: str) -> RegisteredTool:
|
|
37
|
+
try:
|
|
38
|
+
return self._tools[name]
|
|
39
|
+
except KeyError:
|
|
40
|
+
raise KeyError(f"unknown tool: {name!r}") from None
|
|
41
|
+
|
|
42
|
+
def catalog(self) -> list[ToolSpec]:
|
|
43
|
+
"""Specs only — names, schemas, effect levels. Safe to show the planner."""
|
|
44
|
+
return [t.spec for t in self._tools.values()]
|