clearframe 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.
clearframe/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """
2
+ ClearFrame — Open-source AI agent protocol with auditability,
3
+ goal monitoring, Reader/Actor isolation, and safety controls.
4
+
5
+ A production-grade alternative to OpenClaw and MCP.
6
+ """
7
+
8
+ from clearframe.core.config import ClearFrameConfig
9
+ from clearframe.core.manifest import GoalManifest
10
+ from clearframe.core.session import AgentSession
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = ["ClearFrameConfig", "GoalManifest", "AgentSession", "__version__"]
clearframe/cli.py ADDED
@@ -0,0 +1,131 @@
1
+ """ClearFrame CLI — clearframe init / audit-verify / audit-tail / ops-start / version"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ app = typer.Typer(
13
+ name="clearframe",
14
+ help="ClearFrame — AI agent protocol with auditability and safety controls.",
15
+ no_args_is_help=True,
16
+ )
17
+ console = Console()
18
+
19
+
20
+ @app.command()
21
+ def init(name: str = typer.Argument(..., help="Project name")) -> None:
22
+ """Initialise a new ClearFrame agent project."""
23
+ path = Path(name)
24
+ path.mkdir(exist_ok=True)
25
+ (path / "agent.py").write_text(
26
+ f'"""ClearFrame agent: {name}"""\n\n'
27
+ "import asyncio\n"
28
+ "from clearframe import AgentSession, GoalManifest, ClearFrameConfig\n"
29
+ "from clearframe.core.manifest import ToolPermission\n\n\n"
30
+ "async def main() -> None:\n"
31
+ " config = ClearFrameConfig()\n"
32
+ " manifest = GoalManifest(\n"
33
+ " goal='Describe your agent goal here',\n"
34
+ " permitted_tools=[ToolPermission(tool_name='web_search')],\n"
35
+ " )\n"
36
+ " async with AgentSession(config, manifest) as session:\n"
37
+ " result = await session.call_tool('web_search', query='hello world')\n"
38
+ " print(result)\n\n\n"
39
+ "if __name__ == '__main__':\n"
40
+ " asyncio.run(main())\n"
41
+ )
42
+ (path / "clearframe.json").write_text(
43
+ json.dumps({"name": name, "version": "0.1.0"}, indent=2) + "\n"
44
+ )
45
+ console.print(f"[green]✓[/green] Initialised ClearFrame project: [bold]{name}[/bold]")
46
+ console.print(f" Edit [cyan]{name}/agent.py[/cyan] to define your agent.")
47
+ console.print(f" Run: [cyan]cd {name} && python agent.py[/cyan]")
48
+
49
+
50
+ @app.command(name="audit-verify")
51
+ def audit_verify(
52
+ session_id: str = typer.Option(None, "--session", "-s", help="Filter by session ID"),
53
+ log_path: Path = typer.Option(
54
+ Path.home() / ".clearframe" / "audit.log", "--log", help="Path to audit log"
55
+ ),
56
+ ) -> None:
57
+ """Verify HMAC chain integrity of the audit log."""
58
+ from clearframe.core.audit import AuditLog
59
+ from clearframe.core.config import AuditConfig
60
+
61
+ cfg = AuditConfig(log_path=log_path)
62
+ audit = AuditLog(cfg)
63
+ ok, errors = audit.verify()
64
+ if ok:
65
+ console.print("[green]✓ Audit log integrity verified — no tampering detected.[/green]")
66
+ else:
67
+ console.print(f"[red]✗ TAMPERING DETECTED — {len(errors)} error(s):[/red]")
68
+ for err in errors:
69
+ console.print(f" [red]{err}[/red]")
70
+ raise typer.Exit(code=1)
71
+
72
+
73
+ @app.command(name="audit-tail")
74
+ def audit_tail(
75
+ n: int = typer.Option(20, "--lines", "-n", help="Number of entries to show"),
76
+ log_path: Path = typer.Option(
77
+ Path.home() / ".clearframe" / "audit.log", "--log", help="Path to audit log"
78
+ ),
79
+ ) -> None:
80
+ """Show recent audit log entries."""
81
+ from clearframe.core.audit import AuditLog
82
+ from clearframe.core.config import AuditConfig
83
+
84
+ cfg = AuditConfig(log_path=log_path)
85
+ audit = AuditLog(cfg)
86
+ entries = audit.tail(n)
87
+ if not entries:
88
+ console.print("[dim]No audit entries found.[/dim]")
89
+ return
90
+ table = Table(title=f"Last {n} Audit Entries", show_lines=True)
91
+ table.add_column("Seq", style="dim", width=6)
92
+ table.add_column("Event", style="cyan")
93
+ table.add_column("Session", style="dim", width=14)
94
+ table.add_column("Timestamp", style="dim")
95
+ for e in entries:
96
+ table.add_row(
97
+ str(e.get("seq", "")),
98
+ e.get("event_type", ""),
99
+ str(e.get("session_id", ""))[:12] + "…",
100
+ str(e.get("timestamp", ""))[:19],
101
+ )
102
+ console.print(table)
103
+
104
+
105
+ @app.command(name="ops-start")
106
+ def ops_start(
107
+ host: str = typer.Option("127.0.0.1", "--host"),
108
+ port: int = typer.Option(7477, "--port"),
109
+ ) -> None:
110
+ """Start the AgentOps control plane server."""
111
+ import uvicorn
112
+ from clearframe.core.config import OpsConfig
113
+ from clearframe.ops.server import create_ops_app, _ops_token
114
+
115
+ config = OpsConfig(host=host, port=port)
116
+ ops_app = create_ops_app(config)
117
+ console.print(f"[green]✓ ClearFrame AgentOps starting at http://{host}:{port}[/green]")
118
+ console.print(f" [yellow]Auth token:[/yellow] {_ops_token}")
119
+ console.print(" [dim]Keep this token private — it grants full control.[/dim]")
120
+ uvicorn.run(ops_app, host=host, port=port)
121
+
122
+
123
+ @app.command()
124
+ def version() -> None:
125
+ """Show ClearFrame version."""
126
+ from clearframe import __version__
127
+ console.print(f"ClearFrame v{__version__}")
128
+
129
+
130
+ if __name__ == "__main__":
131
+ app()
File without changes
@@ -0,0 +1,159 @@
1
+ """
2
+ ClearFrame HMAC-Chained Audit Log
3
+
4
+ Every event is linked to the previous via HMAC-SHA256.
5
+ Deleting or editing any entry breaks the chain — detectable immediately.
6
+
7
+ Contrast with OpenClaw/MCP: no audit trail exists. Post-incident
8
+ forensics is impossible.
9
+
10
+ Format: newline-delimited JSON (.jsonl)
11
+ Each line:
12
+ {
13
+ "seq": 1,
14
+ "timestamp": "2026-04-12T03:00:00Z",
15
+ "event_type": "SESSION_START",
16
+ "session_id": "abc-123",
17
+ "data": {...},
18
+ "prev_hmac": "0000...0000", // "0" * 64 for seq=1
19
+ "hmac": "sha256-hex"
20
+ }
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import hmac
27
+ import json
28
+ import os
29
+ import time
30
+ from enum import Enum
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from clearframe.core.config import AuditConfig
35
+
36
+
37
+ class EventType(str, Enum):
38
+ SESSION_START = "SESSION_START"
39
+ SESSION_END = "SESSION_END"
40
+ TOOL_CALL_REQUESTED = "TOOL_CALL_REQUESTED"
41
+ TOOL_CALL_APPROVED = "TOOL_CALL_APPROVED"
42
+ TOOL_CALL_BLOCKED = "TOOL_CALL_BLOCKED"
43
+ TOOL_CALL_COMPLETED = "TOOL_CALL_COMPLETED"
44
+ GOAL_SCORE = "GOAL_SCORE"
45
+ CONTEXT_HASH = "CONTEXT_HASH"
46
+ VAULT_ACCESS = "VAULT_ACCESS"
47
+ PLUGIN_LOADED = "PLUGIN_LOADED"
48
+ SECURITY_ALERT = "SECURITY_ALERT"
49
+ OPERATOR_DECISION = "OPERATOR_DECISION"
50
+
51
+
52
+ _DEFAULT_SECRET = b"clearframe-default-audit-secret-change-in-production"
53
+
54
+
55
+ class AuditLog:
56
+ """
57
+ HMAC-SHA256 chained append-only audit log.
58
+
59
+ Each entry's HMAC covers: seq + timestamp + event_type + session_id + data + prev_hmac.
60
+ Verifying the chain detects any insertion, deletion, or modification.
61
+ """
62
+
63
+ def __init__(self, config: AuditConfig) -> None:
64
+ self._config = config
65
+ self._secret = os.environb.get(
66
+ config.hmac_secret_env.encode(), _DEFAULT_SECRET
67
+ )
68
+ self._seq = 0
69
+ self._prev_hmac = "0" * 64
70
+ if config.enabled and config.log_path.exists():
71
+ entries = self._read_all()
72
+ if entries:
73
+ last = entries[-1]
74
+ self._seq = last.get("seq", 0)
75
+ self._prev_hmac = last.get("hmac", "0" * 64)
76
+
77
+ def write(
78
+ self,
79
+ event_type: EventType,
80
+ session_id: str,
81
+ data: dict[str, Any],
82
+ ) -> dict[str, Any]:
83
+ self._seq += 1
84
+ timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
85
+ entry: dict[str, Any] = {
86
+ "seq": self._seq,
87
+ "timestamp": timestamp,
88
+ "event_type": event_type.value,
89
+ "session_id": session_id,
90
+ "data": data,
91
+ "prev_hmac": self._prev_hmac,
92
+ }
93
+ entry["hmac"] = self._compute_hmac(entry)
94
+ self._prev_hmac = entry["hmac"]
95
+ if self._config.enabled:
96
+ self._config.log_path.parent.mkdir(parents=True, exist_ok=True)
97
+ with open(self._config.log_path, "a", encoding="utf-8") as f:
98
+ f.write(json.dumps(entry) + "\n")
99
+ return entry
100
+
101
+ def verify(self) -> tuple[bool, list[str]]:
102
+ """Verify the full HMAC chain. Returns (ok, list_of_errors)."""
103
+ errors: list[str] = []
104
+ entries = self._read_all()
105
+ prev = "0" * 64
106
+ for entry in entries:
107
+ stored_hmac = entry.get("hmac", "")
108
+ check = {k: v for k, v in entry.items() if k != "hmac"}
109
+ check["prev_hmac"] = prev
110
+ expected = self._compute_hmac(check)
111
+ if not hmac.compare_digest(stored_hmac, expected):
112
+ errors.append(
113
+ f"Chain broken at seq={entry.get('seq')} "
114
+ f"event={entry.get('event_type')} — HMAC mismatch"
115
+ )
116
+ prev = stored_hmac
117
+ return len(errors) == 0, errors
118
+
119
+ def query(
120
+ self,
121
+ session_id: str | None = None,
122
+ event_type: str | None = None,
123
+ limit: int = 100,
124
+ ) -> list[dict[str, Any]]:
125
+ entries = self._read_all()
126
+ if session_id:
127
+ entries = [e for e in entries if e.get("session_id") == session_id]
128
+ if event_type:
129
+ entries = [e for e in entries if e.get("event_type") == event_type]
130
+ return entries[-limit:]
131
+
132
+ def tail(self, n: int = 20) -> list[dict[str, Any]]:
133
+ return self._read_all()[-n:]
134
+
135
+ # ------------------------------------------------------------------
136
+ # Internals
137
+ # ------------------------------------------------------------------
138
+
139
+ def _compute_hmac(self, entry: dict[str, Any]) -> str:
140
+ canonical = json.dumps(
141
+ {k: entry[k] for k in sorted(entry.keys()) if k != "hmac"},
142
+ sort_keys=True,
143
+ separators=(",", ":"),
144
+ ).encode()
145
+ return hmac.new(self._secret, canonical, hashlib.sha256).hexdigest()
146
+
147
+ def _read_all(self) -> list[dict[str, Any]]:
148
+ if not self._config.log_path.exists():
149
+ return []
150
+ entries = []
151
+ with open(self._config.log_path, encoding="utf-8") as f:
152
+ for line in f:
153
+ line = line.strip()
154
+ if line:
155
+ try:
156
+ entries.append(json.loads(line))
157
+ except json.JSONDecodeError:
158
+ pass
159
+ return entries
@@ -0,0 +1,52 @@
1
+ """
2
+ ClearFrame Configuration
3
+
4
+ Secure defaults throughout. Every dangerous option is OFF by default.
5
+ Contrast with OpenClaw which binds to 0.0.0.0 and requires no auth.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from pydantic import BaseModel, Field
12
+
13
+
14
+ class VaultConfig(BaseModel):
15
+ vault_path: Path = Field(default=Path("~/.clearframe/vault.enc").expanduser())
16
+ salt_path: Path = Field(default=Path("~/.clearframe/vault.salt").expanduser())
17
+ pbkdf2_iterations: int = 600_000 # OWASP 2024 minimum is 210,000 — we exceed it
18
+
19
+
20
+ class AuditConfig(BaseModel):
21
+ log_path: Path = Field(default=Path("~/.clearframe/audit.log").expanduser())
22
+ enabled: bool = True
23
+ hmac_secret_env: str = "CLEARFRAME_AUDIT_SECRET"
24
+
25
+
26
+ class RTLConfig(BaseModel):
27
+ rtl_path: Path = Field(default=Path("~/.clearframe/rtl").expanduser())
28
+ enabled: bool = True
29
+
30
+
31
+ class GoalMonitorConfig(BaseModel):
32
+ alignment_threshold: float = 0.55 # Below this → block or queue
33
+ auto_approve_threshold: float = 0.85 # Above this → auto-approve
34
+ pause_on_ambiguous: bool = True # True → queue; False → block
35
+ max_consecutive_low_scores: int = 3 # Suspend session after N low scores
36
+
37
+
38
+ class OpsConfig(BaseModel):
39
+ host: str = "127.0.0.1" # Localhost only — never 0.0.0.0 by default
40
+ port: int = 7477
41
+ require_auth: bool = True
42
+ cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:3000"])
43
+
44
+
45
+ class ClearFrameConfig(BaseModel):
46
+ vault: VaultConfig = Field(default_factory=VaultConfig)
47
+ audit: AuditConfig = Field(default_factory=AuditConfig)
48
+ rtl: RTLConfig = Field(default_factory=RTLConfig)
49
+ goal_monitor: GoalMonitorConfig = Field(default_factory=GoalMonitorConfig)
50
+ ops: OpsConfig = Field(default_factory=OpsConfig)
51
+ # Never bind to all interfaces by default (OpenClaw CVE-2024-6940 root cause)
52
+ allow_remote_connections: bool = False
@@ -0,0 +1,88 @@
1
+ """
2
+ ClearFrame GoalManifest
3
+
4
+ Every agent session MUST declare:
5
+ - A plain-English goal
6
+ - The exact set of tools it is permitted to use
7
+ - Resource scope (allowed domains, paths)
8
+ - Whether file writes and code execution are permitted
9
+
10
+ This declaration is written to the audit log at session start and
11
+ evaluated by the Goal Monitor on every tool call.
12
+
13
+ Contrast with OpenClaw/MCP: there is no concept of a declared goal.
14
+ The runtime has no idea what the agent is supposed to be doing,
15
+ so it cannot detect drift.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Optional
21
+ from pydantic import BaseModel, Field
22
+
23
+
24
+ class ResourceScope(BaseModel):
25
+ """Defines the external resources an agent is permitted to access."""
26
+ allowed_domains: list[str] = Field(
27
+ default_factory=list,
28
+ description=(
29
+ "Domains the agent may fetch. Supports * wildcard. "
30
+ "Empty list = unrestricted (logged but not blocked)."
31
+ ),
32
+ )
33
+ allowed_paths: list[str] = Field(
34
+ default_factory=list,
35
+ description="Filesystem paths the agent may read. Empty = none.",
36
+ )
37
+ allowed_write_paths: list[str] = Field(
38
+ default_factory=list,
39
+ description="Filesystem paths the agent may write. Empty = none.",
40
+ )
41
+
42
+
43
+ class ToolPermission(BaseModel):
44
+ """Declares permission for a single tool."""
45
+ tool_name: str
46
+ max_calls_per_session: Optional[int] = None # None = unlimited
47
+ require_approval: bool = False # True = always queue for operator
48
+ allowed_arg_patterns: dict[str, str] = Field(
49
+ default_factory=dict,
50
+ description="Optional regex patterns for arg validation. e.g. {'query': '^[a-zA-Z ]+$'}",
51
+ )
52
+
53
+
54
+ class GoalManifest(BaseModel):
55
+ """
56
+ The complete declaration of what an agent session is allowed to do.
57
+
58
+ Immutable once a session starts. Any attempt to modify it after
59
+ session start raises ManifestLockError.
60
+ """
61
+
62
+ goal: str = Field(
63
+ ...,
64
+ description="Plain-English statement of what this agent session should accomplish.",
65
+ min_length=10,
66
+ max_length=2000,
67
+ )
68
+ permitted_tools: list[ToolPermission] = Field(
69
+ default_factory=list,
70
+ description="Explicit allowlist of tools. Tools not listed are blocked.",
71
+ )
72
+ resource_scope: ResourceScope = Field(default_factory=ResourceScope)
73
+ allow_file_write: bool = False
74
+ allow_code_execution: bool = False
75
+ max_steps: Optional[int] = 50
76
+ auto_pause_on_drift: bool = True
77
+
78
+ # Internal — set by AgentSession on start
79
+ _locked: bool = False
80
+
81
+ def lock(self) -> None:
82
+ object.__setattr__(self, "_locked", True)
83
+
84
+ def is_tool_permitted(self, tool_name: str) -> bool:
85
+ return any(p.tool_name == tool_name for p in self.permitted_tools)
86
+
87
+ def get_tool_permission(self, tool_name: str) -> Optional[ToolPermission]:
88
+ return next((p for p in self.permitted_tools if p.tool_name == tool_name), None)
@@ -0,0 +1,192 @@
1
+ """
2
+ ClearFrame AgentSession — the main runtime orchestrator.
3
+
4
+ Coordinates:
5
+ 1. GoalManifest declaration and lock
6
+ 2. Vault (credentials)
7
+ 3. Reader/Actor isolation layer
8
+ 4. Goal Monitor (alignment scoring on every tool call)
9
+ 5. RTL (reasoning trace recording)
10
+ 6. Audit log (HMAC-chained, every event)
11
+ 7. Context Feed Auditor (hash every context chunk)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import time
18
+ import uuid
19
+ from typing import Any, Callable
20
+
21
+ from clearframe.core.audit import AuditLog, EventType
22
+ from clearframe.core.config import ClearFrameConfig
23
+ from clearframe.core.manifest import GoalManifest
24
+ from clearframe.core.vault import Vault
25
+ from clearframe.monitor.goal_monitor import GoalMonitor, Disposition
26
+ from clearframe.monitor.rtl import RTL
27
+ from clearframe.gateway.isolation import MessagePipe, ReaderSandbox, ActorSandbox
28
+
29
+
30
+ class SessionError(Exception):
31
+ pass
32
+
33
+
34
+ class AgentSession:
35
+ """
36
+ A single ClearFrame agent session.
37
+
38
+ Usage:
39
+ config = ClearFrameConfig()
40
+ manifest = GoalManifest(
41
+ goal="Search for AI safety papers and summarise them",
42
+ permitted_tools=[ToolPermission(tool_name="web_search", max_calls_per_session=5)],
43
+ )
44
+ async with AgentSession(config, manifest) as session:
45
+ result = await session.call_tool("web_search", query="AI safety 2026")
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ config: ClearFrameConfig,
51
+ manifest: GoalManifest,
52
+ tool_registry: dict[str, Callable] | None = None,
53
+ ) -> None:
54
+ self._config = config
55
+ self._manifest = manifest
56
+ self._tools = tool_registry or {}
57
+ self._session_id = str(uuid.uuid4())
58
+ self._start_time = time.time()
59
+
60
+ self._audit = AuditLog(config.audit)
61
+ self._vault = Vault(config.vault)
62
+ self._monitor = GoalMonitor(manifest, config.goal_monitor)
63
+ self._rtl = RTL(self._session_id, config.rtl)
64
+
65
+ self._pipe = MessagePipe()
66
+ self._reader = ReaderSandbox(self._session_id, self._pipe)
67
+ self._actor = ActorSandbox(self._session_id, self._pipe, self._tools)
68
+
69
+ self._context_chunks: list[dict] = []
70
+
71
+ # ------------------------------------------------------------------
72
+ # Lifecycle
73
+ # ------------------------------------------------------------------
74
+
75
+ async def start(self) -> None:
76
+ self._manifest.lock()
77
+ self._audit.write(EventType.SESSION_START, self._session_id, {
78
+ "manifest_goal": self._manifest.goal[:200],
79
+ "permitted_tools": [p.tool_name for p in self._manifest.permitted_tools],
80
+ "allow_file_write": self._manifest.allow_file_write,
81
+ "allow_code_execution": self._manifest.allow_code_execution,
82
+ "max_steps": self._manifest.max_steps,
83
+ })
84
+
85
+ async def end(self, outcome: str = "completed") -> None:
86
+ elapsed = time.time() - self._start_time
87
+ self._audit.write(EventType.SESSION_END, self._session_id, {
88
+ "outcome": outcome,
89
+ "elapsed_seconds": round(elapsed, 2),
90
+ "monitor_stats": self._monitor.stats(),
91
+ "context_chunks": len(self._context_chunks),
92
+ })
93
+ self._vault.lock()
94
+
95
+ # ------------------------------------------------------------------
96
+ # Tool call — main user-facing method
97
+ # ------------------------------------------------------------------
98
+
99
+ async def call_tool(self, tool_name: str, **kwargs: Any) -> Any:
100
+ """
101
+ Request a tool call. The Goal Monitor evaluates alignment first.
102
+ Only approved / auto-approved calls reach the Actor sandbox.
103
+ """
104
+ args = dict(kwargs)
105
+ self._rtl.record("tool_call", f"{tool_name}({args})")
106
+
107
+ scored = self._monitor.evaluate(tool_name, args)
108
+ self._audit.write(EventType.GOAL_SCORE, self._session_id, {
109
+ "tool_name": tool_name,
110
+ "score": scored.alignment_score,
111
+ "disposition": scored.disposition.value,
112
+ "reasons": scored.reasons,
113
+ })
114
+
115
+ if scored.disposition == Disposition.BLOCK:
116
+ self._audit.write(EventType.TOOL_CALL_BLOCKED, self._session_id, {
117
+ "tool_name": tool_name, "reasons": scored.reasons,
118
+ })
119
+ raise SessionError(
120
+ f"[ClearFrame] Tool '{tool_name}' BLOCKED. "
121
+ f"Score: {scored.alignment_score}. Reasons: {scored.reasons}"
122
+ )
123
+
124
+ if scored.disposition == Disposition.QUEUE:
125
+ self._audit.write(EventType.TOOL_CALL_REQUESTED, self._session_id, {
126
+ "tool_name": tool_name,
127
+ "score": scored.alignment_score,
128
+ "status": "pending_operator_approval",
129
+ })
130
+ raise SessionError(
131
+ f"[ClearFrame] Tool '{tool_name}' queued for operator approval "
132
+ f"(score: {scored.alignment_score}). Check AgentOps dashboard."
133
+ )
134
+
135
+ # Approved — execute via Actor sandbox
136
+ self._audit.write(EventType.TOOL_CALL_APPROVED, self._session_id, {
137
+ "tool_name": tool_name, "score": scored.alignment_score,
138
+ })
139
+ result = await self._actor.execute_approved_call(tool_name, args)
140
+ self._audit.write(EventType.TOOL_CALL_COMPLETED, self._session_id, {
141
+ "tool_name": tool_name, "result_preview": str(result)[:200],
142
+ })
143
+ return result
144
+
145
+ # ------------------------------------------------------------------
146
+ # Context Feed Auditor
147
+ # ------------------------------------------------------------------
148
+
149
+ async def ingest_context(self, content: str, source: str) -> str:
150
+ """
151
+ Ingest untrusted content through the Reader sandbox.
152
+ Source-tagged and SHA-256 hashed into the audit log.
153
+ Returns the content hash.
154
+ """
155
+ content_hash = hashlib.sha256(content.encode()).hexdigest()
156
+ self._audit.write(EventType.CONTEXT_HASH, self._session_id, {
157
+ "source": source, "length": len(content), "sha256": content_hash,
158
+ })
159
+ await self._reader.ingest_text(content, source)
160
+ self._context_chunks.append({"source": source, "hash": content_hash})
161
+ return content_hash
162
+
163
+ # ------------------------------------------------------------------
164
+ # Context manager
165
+ # ------------------------------------------------------------------
166
+
167
+ async def __aenter__(self) -> "AgentSession":
168
+ await self.start()
169
+ return self
170
+
171
+ async def __aexit__(self, exc_type: Any, *_: Any) -> None:
172
+ await self.end("error" if exc_type else "completed")
173
+
174
+ # ------------------------------------------------------------------
175
+ # Properties
176
+ # ------------------------------------------------------------------
177
+
178
+ @property
179
+ def session_id(self) -> str:
180
+ return self._session_id
181
+
182
+ @property
183
+ def audit(self) -> AuditLog:
184
+ return self._audit
185
+
186
+ @property
187
+ def rtl(self) -> RTL:
188
+ return self._rtl
189
+
190
+ @property
191
+ def monitor(self) -> GoalMonitor:
192
+ return self._monitor