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 +13 -0
- clearframe/cli.py +131 -0
- clearframe/core/__init__.py +0 -0
- clearframe/core/audit.py +159 -0
- clearframe/core/config.py +52 -0
- clearframe/core/manifest.py +88 -0
- clearframe/core/session.py +192 -0
- clearframe/core/vault.py +151 -0
- clearframe/gateway/__init__.py +0 -0
- clearframe/gateway/isolation.py +117 -0
- clearframe/monitor/__init__.py +0 -0
- clearframe/monitor/goal_monitor.py +181 -0
- clearframe/monitor/rtl.py +78 -0
- clearframe/ops/__init__.py +0 -0
- clearframe/ops/server.py +143 -0
- clearframe/plugins/__init__.py +0 -0
- clearframe-0.1.0.dist-info/METADATA +295 -0
- clearframe-0.1.0.dist-info/RECORD +21 -0
- clearframe-0.1.0.dist-info/WHEEL +4 -0
- clearframe-0.1.0.dist-info/entry_points.txt +2 -0
- clearframe-0.1.0.dist-info/licenses/LICENSE +58 -0
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
|
clearframe/core/audit.py
ADDED
|
@@ -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
|