axor-daemon 0.2.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.
@@ -0,0 +1,4 @@
1
+ """axor-daemon — process-isolated capability executor for axor-core."""
2
+ from axor_daemon._version import get_version
3
+
4
+ __version__ = get_version("axor-daemon")
@@ -0,0 +1,205 @@
1
+ """
2
+ CLI entry point for AxorDaemon.
3
+
4
+ Usage:
5
+ python -m axor_daemon start [--socket PATH] [--policy NAME] [--log-level LEVEL]
6
+ [--handler module.path:ClassName ...]
7
+ axor-daemon start [--socket PATH] [--policy NAME]
8
+
9
+ Handlers are loaded at startup via --handler (repeatable). Each value is a
10
+ dotted module path and class name separated by ':', e.g.:
11
+ --handler axor_claude.tools.read:ReadHandler
12
+ --handler axor_claude.tools.bash:BashHandler
13
+
14
+ The class is instantiated with no arguments; its .name property is used as the
15
+ tool name key. axor-daemon has no direct dependency on handler packages —
16
+ they are imported dynamically at runtime.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import asyncio
23
+ import importlib
24
+ import logging
25
+ import signal
26
+ import sys
27
+
28
+ _DEFAULT_SOCKET = "~/.axor/daemon.sock"
29
+ _DEFAULT_POLICY = "focused_generative"
30
+
31
+ _POLICY_NAMES = {
32
+ "focused_readonly",
33
+ "focused_generative",
34
+ "focused_mutative",
35
+ "moderate_readonly",
36
+ "moderate_generative",
37
+ "moderate_mutative",
38
+ "expansive",
39
+ }
40
+
41
+
42
+ def _build_operator_policy(policy_name: str):
43
+ from axor_core.contracts.policy import (
44
+ TaskSignal, TaskComplexity, TaskNature,
45
+ )
46
+ from axor_core.policy.selector import PolicySelector
47
+
48
+ # Resolve name → signal → policy via PolicySelector
49
+ _name_to_signal = {
50
+ "focused_readonly": (TaskComplexity.FOCUSED, TaskNature.READONLY),
51
+ "focused_generative": (TaskComplexity.FOCUSED, TaskNature.GENERATIVE),
52
+ "focused_mutative": (TaskComplexity.FOCUSED, TaskNature.MUTATIVE),
53
+ "moderate_readonly": (TaskComplexity.MODERATE, TaskNature.READONLY),
54
+ "moderate_generative": (TaskComplexity.MODERATE, TaskNature.GENERATIVE),
55
+ "moderate_mutative": (TaskComplexity.MODERATE, TaskNature.MUTATIVE),
56
+ "expansive": (TaskComplexity.EXPANSIVE, TaskNature.MUTATIVE),
57
+ }
58
+ if policy_name not in _name_to_signal:
59
+ print(f"Unknown policy '{policy_name}'. Valid: {sorted(_POLICY_NAMES)}", file=sys.stderr)
60
+ sys.exit(1)
61
+
62
+ complexity, nature = _name_to_signal[policy_name]
63
+ signal = TaskSignal(
64
+ raw_input="",
65
+ complexity=complexity,
66
+ nature=nature,
67
+ estimated_scope=1,
68
+ requires_children=False,
69
+ requires_mutation=(nature == TaskNature.MUTATIVE),
70
+ )
71
+ return PolicySelector().select(signal)
72
+
73
+
74
+ def _load_handlers(specs: list[str]) -> dict:
75
+ """
76
+ Load ToolHandler instances from 'module.path:ClassName' spec strings.
77
+ Each handler is instantiated with no arguments; its .name is the dict key.
78
+ """
79
+ handlers = {}
80
+ for spec in specs:
81
+ if ":" not in spec:
82
+ print(
83
+ f"Invalid --handler spec '{spec}'. Expected 'module.path:ClassName'.",
84
+ file=sys.stderr,
85
+ )
86
+ sys.exit(1)
87
+ module_path, class_name = spec.rsplit(":", 1)
88
+ try:
89
+ module = importlib.import_module(module_path)
90
+ except ImportError as e:
91
+ print(f"Cannot import '{module_path}': {e}", file=sys.stderr)
92
+ sys.exit(1)
93
+ cls = getattr(module, class_name, None)
94
+ if cls is None:
95
+ print(
96
+ f"Class '{class_name}' not found in module '{module_path}'.",
97
+ file=sys.stderr,
98
+ )
99
+ sys.exit(1)
100
+ handler = cls()
101
+ handlers[handler.name] = handler
102
+ logging.info("Registered handler '%s' from %s", handler.name, spec)
103
+ return handlers
104
+
105
+
106
+ def _build_enforcer(operator_policy, handlers: dict, sandbox_root: str | None = None):
107
+ from axor_daemon.enforcer import DaemonEnforcer
108
+ return DaemonEnforcer(
109
+ operator_policy=operator_policy,
110
+ handlers=handlers,
111
+ sandbox_root=sandbox_root,
112
+ )
113
+
114
+
115
+ async def _run(
116
+ socket_path: str,
117
+ policy_name: str,
118
+ handler_specs: list[str],
119
+ sandbox_root: str | None = None,
120
+ ) -> None:
121
+ from axor_daemon.server import DaemonServer
122
+
123
+ operator_policy = _build_operator_policy(policy_name)
124
+ handlers = _load_handlers(handler_specs)
125
+ enforcer = _build_enforcer(
126
+ operator_policy,
127
+ handlers=handlers,
128
+ sandbox_root=sandbox_root,
129
+ )
130
+ server = DaemonServer(enforcer=enforcer)
131
+ await server.start(socket_path)
132
+
133
+ loop = asyncio.get_running_loop()
134
+ stop_event = asyncio.Event()
135
+
136
+ def _handle_signal():
137
+ stop_event.set()
138
+
139
+ for sig in (signal.SIGINT, signal.SIGTERM):
140
+ loop.add_signal_handler(sig, _handle_signal)
141
+
142
+ logging.info(
143
+ "AxorDaemon started — policy=%s socket=%s handlers=%s sandbox_root=%s",
144
+ policy_name, socket_path, sorted(handlers), sandbox_root or "<none>",
145
+ )
146
+
147
+ await stop_event.wait()
148
+ await server.stop()
149
+ logging.info("AxorDaemon stopped")
150
+
151
+
152
+ def main() -> None:
153
+ parser = argparse.ArgumentParser(
154
+ prog="axor-daemon",
155
+ description="AxorDaemon — process-isolated capability executor",
156
+ )
157
+ sub = parser.add_subparsers(dest="command")
158
+
159
+ start_p = sub.add_parser("start", help="Start the daemon")
160
+ start_p.add_argument(
161
+ "--socket", default=_DEFAULT_SOCKET,
162
+ help=f"Unix socket path (default: {_DEFAULT_SOCKET})",
163
+ )
164
+ start_p.add_argument(
165
+ "--policy", default=_DEFAULT_POLICY,
166
+ help=f"Operator policy ceiling (default: {_DEFAULT_POLICY}). "
167
+ f"Valid: {sorted(_POLICY_NAMES)}",
168
+ )
169
+ start_p.add_argument(
170
+ "--log-level", default="INFO",
171
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
172
+ )
173
+ start_p.add_argument(
174
+ "--handler", dest="handlers", action="append", default=[],
175
+ metavar="MODULE:CLASS",
176
+ help=(
177
+ "Register a ToolHandler. Repeatable. Format: 'module.path:ClassName'. "
178
+ "Example: --handler axor_claude.tools.read:ReadHandler"
179
+ ),
180
+ )
181
+ start_p.add_argument(
182
+ "--sandbox-root",
183
+ default=os.environ.get("AXOR_DAEMON_SANDBOX_ROOT"),
184
+ help=(
185
+ "Optional filesystem root for daemon-side path args. "
186
+ "Also configurable via AXOR_DAEMON_SANDBOX_ROOT."
187
+ ),
188
+ )
189
+
190
+ args = parser.parse_args()
191
+
192
+ if args.command is None:
193
+ parser.print_help()
194
+ sys.exit(0)
195
+
196
+ logging.basicConfig(
197
+ level=getattr(logging, args.log_level),
198
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
199
+ )
200
+
201
+ asyncio.run(_run(args.socket, args.policy, args.handlers, args.sandbox_root))
202
+
203
+
204
+ if __name__ == "__main__":
205
+ main()
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version as metadata_version
4
+ from pathlib import Path
5
+ import tomllib
6
+
7
+
8
+ def get_version(distribution_name: str) -> str:
9
+ pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
10
+ if pyproject.exists():
11
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
12
+ return str(data["project"]["version"])
13
+ try:
14
+ return metadata_version(distribution_name)
15
+ except PackageNotFoundError:
16
+ return "0.0.0"
@@ -0,0 +1,143 @@
1
+ """
2
+ DaemonEnforcer — validates tool calls against policy and executes approved ones.
3
+
4
+ Two-ceiling enforcement:
5
+ 1. client_allowed_tools — derived by GovernedSession from session policy
6
+ 2. operator_allowed_tools — derived from operator_policy set at daemon startup
7
+
8
+ Tool executes only when approved by both. Operator ceiling cannot be escalated
9
+ by the client — it is set once at daemon start and never modified per-connection.
10
+
11
+ Args are never trusted raw from the client:
12
+ - tool name is validated against both ceilings before handler is called
13
+ - path-like string args are normalized independently, and when sandbox_root is
14
+ set they must resolve inside that root before handler execution
15
+ - handler execution is bounded by exec_timeout to prevent runaway tools
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ import os
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from axor_core.capability.executor import ToolHandler
27
+ from axor_core.capability.resolver import CapabilityResolver
28
+ from axor_core.contracts.policy import ExecutionPolicy
29
+
30
+ _log = logging.getLogger("axor.daemon.enforcer")
31
+
32
+ # Path-like args the enforcer normalizes independently.
33
+ # Handlers may have additional args — only these are path-normalized.
34
+ _PATH_ARG_KEYS = frozenset({"path", "file", "filepath", "filename", "target"})
35
+
36
+ # Default max seconds any single handler.execute() may run.
37
+ _DEFAULT_EXEC_TIMEOUT = 60.0
38
+
39
+
40
+ class DaemonEnforcer:
41
+ def __init__(
42
+ self,
43
+ operator_policy: ExecutionPolicy,
44
+ handlers: dict[str, ToolHandler],
45
+ exec_timeout: float = _DEFAULT_EXEC_TIMEOUT,
46
+ sandbox_root: str | None = None,
47
+ ) -> None:
48
+ self._operator_policy = operator_policy
49
+ self._handlers = handlers
50
+ self._exec_timeout = exec_timeout
51
+ self._sandbox_root = (
52
+ Path(sandbox_root).expanduser().resolve(strict=False)
53
+ if sandbox_root else None
54
+ )
55
+ resolver = CapabilityResolver()
56
+ caps = resolver.resolve(operator_policy)
57
+ self._operator_allowed: frozenset[str] = caps.allowed_tools
58
+
59
+ async def execute(
60
+ self,
61
+ call_id: str,
62
+ tool: str,
63
+ args: dict[str, Any],
64
+ client_allowed_tools: frozenset[str],
65
+ ) -> tuple[str, Any, str | None]:
66
+ """
67
+ Validate and execute a tool call.
68
+
69
+ Returns (decision, result, denial_reason):
70
+ decision: "approved" | "denied"
71
+ result: tool output or None
72
+ denial_reason: str if denied, else None
73
+ """
74
+ # ceiling check — operator policy wins, evaluated daemon-side
75
+ if tool not in self._operator_allowed:
76
+ reason = f"tool '{tool}' not permitted by operator policy"
77
+ _log.info("DENIED call_id=%s tool=%s: %s", call_id, tool, reason)
78
+ return "denied", None, reason
79
+
80
+ # client check — session policy reported by client
81
+ if tool not in client_allowed_tools:
82
+ reason = f"tool '{tool}' not in session allowed_tools"
83
+ _log.info("DENIED call_id=%s tool=%s: %s", call_id, tool, reason)
84
+ return "denied", None, reason
85
+
86
+ handler = self._handlers.get(tool)
87
+ if handler is None:
88
+ reason = f"no handler registered for tool '{tool}'"
89
+ _log.warning("DENIED call_id=%s tool=%s: %s", call_id, tool, reason)
90
+ return "denied", None, reason
91
+
92
+ try:
93
+ safe_args = _normalize_path_args(args, sandbox_root=self._sandbox_root)
94
+ except ValueError as exc:
95
+ reason = str(exc)
96
+ _log.info("DENIED call_id=%s tool=%s: %s", call_id, tool, reason)
97
+ return "denied", None, reason
98
+
99
+ try:
100
+ result = await asyncio.wait_for(
101
+ handler.execute(safe_args), timeout=self._exec_timeout
102
+ )
103
+ except asyncio.TimeoutError:
104
+ reason = f"handler '{tool}' exceeded exec_timeout ({self._exec_timeout}s)"
105
+ _log.error("DENIED call_id=%s tool=%s: %s", call_id, tool, reason)
106
+ return "denied", None, reason
107
+
108
+ _log.debug("APPROVED call_id=%s tool=%s", call_id, tool)
109
+ return "approved", result, None
110
+
111
+
112
+ def _normalize_path_args(
113
+ args: dict[str, Any],
114
+ sandbox_root: Path | None = None,
115
+ ) -> dict[str, Any]:
116
+ """
117
+ Return a copy of args with known path-like string values normalized.
118
+ Non-string values and unrecognized keys are passed through unchanged.
119
+ """
120
+ result = {}
121
+ for key, value in args.items():
122
+ if key in _PATH_ARG_KEYS and isinstance(value, str):
123
+ result[key] = _normalize_path(value, sandbox_root=sandbox_root)
124
+ else:
125
+ result[key] = value
126
+ return result
127
+
128
+
129
+ def _normalize_path(value: str, sandbox_root: Path | None) -> str:
130
+ if sandbox_root is None:
131
+ return os.path.normpath(value)
132
+ path = Path(value).expanduser()
133
+ candidate = path if path.is_absolute() else sandbox_root / path
134
+ resolved = candidate.resolve(strict=False)
135
+ if resolved == sandbox_root:
136
+ return str(resolved)
137
+ try:
138
+ resolved.relative_to(sandbox_root)
139
+ except ValueError as exc:
140
+ raise ValueError(
141
+ f"path {str(path)!r} resolves outside daemon sandbox root"
142
+ ) from exc
143
+ return str(resolved)
axor_daemon/server.py ADDED
@@ -0,0 +1,179 @@
1
+ """
2
+ DaemonServer — asyncio Unix socket server.
3
+
4
+ One connection = one session. The server handles multiple concurrent
5
+ connections; each runs in its own coroutine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import os
13
+ import stat
14
+ from typing import Any
15
+
16
+ from axor_core.contracts.daemon import encode_message, read_message, PROTOCOL_VERSION
17
+
18
+ from axor_daemon.enforcer import DaemonEnforcer
19
+
20
+ _log = logging.getLogger("axor.daemon.server")
21
+
22
+ _ALLOWED_MODES = {"library", "production", "strict"}
23
+
24
+ # Seconds to wait for the next message from a connected client.
25
+ # Prevents a stalled/malicious client from holding a session open indefinitely.
26
+ _READ_TIMEOUT = 30.0
27
+
28
+ # Max concurrent connections. Each connection is a coroutine; this caps memory.
29
+ _MAX_CONNECTIONS = 64
30
+
31
+
32
+ class DaemonServer:
33
+ def __init__(self, enforcer: DaemonEnforcer) -> None:
34
+ self._enforcer = enforcer
35
+ self._server: asyncio.Server | None = None
36
+ self._active_connections: set[asyncio.Task] = set()
37
+
38
+ async def start(self, socket_path: str) -> None:
39
+ socket_path = os.path.expanduser(socket_path)
40
+ os.makedirs(os.path.dirname(socket_path) or ".", exist_ok=True)
41
+
42
+ # Remove stale socket from a previous crash
43
+ try:
44
+ existing = os.lstat(socket_path)
45
+ except FileNotFoundError:
46
+ pass
47
+ else:
48
+ if not stat.S_ISSOCK(existing.st_mode):
49
+ raise FileExistsError(
50
+ f"refusing to remove non-socket daemon path: {socket_path}"
51
+ )
52
+ os.unlink(socket_path)
53
+
54
+ self._server = await asyncio.start_unix_server(
55
+ self._handle_client, path=socket_path
56
+ )
57
+
58
+ # Restrict socket to owner only — any local process can connect otherwise.
59
+ os.chmod(socket_path, 0o600)
60
+
61
+ _log.info("AxorDaemon listening on %s (mode 600)", socket_path)
62
+
63
+ async def serve_forever(self) -> None:
64
+ if self._server is None:
65
+ raise RuntimeError("call start() before serve_forever()")
66
+ async with self._server:
67
+ await self._server.serve_forever()
68
+
69
+ async def stop(self) -> None:
70
+ if self._server is not None:
71
+ self._server.close()
72
+ await self._server.wait_closed()
73
+ for task in list(self._active_connections):
74
+ task.cancel()
75
+
76
+ # ── Per-connection handler ─────────────────────────────────────────────────
77
+
78
+ async def _handle_client(
79
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
80
+ ) -> None:
81
+ if len(self._active_connections) >= _MAX_CONNECTIONS:
82
+ _log.warning("max connections (%d) reached — rejecting", _MAX_CONNECTIONS)
83
+ writer.write(encode_message({"type": "rejected", "reason": "server at capacity"}))
84
+ try:
85
+ await writer.drain()
86
+ writer.close()
87
+ except Exception:
88
+ pass
89
+ return
90
+
91
+ peer = writer.get_extra_info("peername") or "unix"
92
+ task = asyncio.current_task()
93
+ self._active_connections.add(task)
94
+ _log.debug("connection from %s (active=%d)", peer, len(self._active_connections))
95
+ try:
96
+ await self._session(reader, writer)
97
+ except asyncio.IncompleteReadError:
98
+ pass # client disconnected mid-message
99
+ except asyncio.TimeoutError:
100
+ _log.info("session timed out: %s", peer)
101
+ except Exception as exc:
102
+ _log.warning("session error from %s: %s", peer, exc)
103
+ finally:
104
+ self._active_connections.discard(task)
105
+ try:
106
+ writer.close()
107
+ await writer.wait_closed()
108
+ except Exception:
109
+ pass
110
+ _log.debug("connection closed: %s", peer)
111
+
112
+ async def _session(
113
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
114
+ ) -> None:
115
+ # ── Handshake ──────────────────────────────────────────────────────────
116
+ hello = await asyncio.wait_for(read_message(reader), timeout=_READ_TIMEOUT)
117
+
118
+ client_version = hello.get("v")
119
+ if client_version != PROTOCOL_VERSION:
120
+ writer.write(encode_message({
121
+ "type": "rejected",
122
+ "reason": f"protocol version mismatch: client={client_version} server={PROTOCOL_VERSION}",
123
+ }))
124
+ await writer.drain()
125
+ return
126
+
127
+ if hello.get("type") != "hello":
128
+ writer.write(encode_message({
129
+ "type": "rejected",
130
+ "reason": f"expected hello, got {hello.get('type')!r}",
131
+ }))
132
+ await writer.drain()
133
+ return
134
+
135
+ mode = hello.get("mode", "library")
136
+ if mode not in _ALLOWED_MODES:
137
+ writer.write(encode_message({
138
+ "type": "rejected",
139
+ "reason": f"unknown mode {mode!r}",
140
+ }))
141
+ await writer.drain()
142
+ return
143
+
144
+ writer.write(encode_message({"type": "ready"}))
145
+ await writer.drain()
146
+
147
+ # ── Request loop ───────────────────────────────────────────────────────
148
+ while True:
149
+ msg = await asyncio.wait_for(read_message(reader), timeout=_READ_TIMEOUT)
150
+ msg_type = msg.get("type")
151
+
152
+ if msg_type == "bye":
153
+ break
154
+
155
+ if msg_type != "tool_call":
156
+ _log.warning("unexpected message type: %r", msg_type)
157
+ continue
158
+
159
+ call_id: str = msg.get("call_id", "")
160
+ tool: str = msg.get("tool", "")
161
+ args: dict[str, Any] = msg.get("args", {})
162
+ client_allowed: frozenset[str] = frozenset(msg.get("allowed_tools", []))
163
+
164
+ decision, result, denial_reason = await self._enforcer.execute(
165
+ call_id=call_id,
166
+ tool=tool,
167
+ args=args,
168
+ client_allowed_tools=client_allowed,
169
+ )
170
+
171
+ response = {
172
+ "type": "tool_result",
173
+ "call_id": call_id,
174
+ "decision": decision,
175
+ "result": result,
176
+ "denial_reason": denial_reason,
177
+ }
178
+ writer.write(encode_message(response))
179
+ await writer.drain()
@@ -0,0 +1,264 @@
1
+ Metadata-Version: 2.4
2
+ Name: axor-daemon
3
+ Version: 0.2.0
4
+ Summary: Process-isolated capability executor for axor-core
5
+ Project-URL: Repository, https://github.com/Bucha11/axor-daemon
6
+ License: MIT
7
+ Keywords: agents,ai,governance,llm,security
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: axor-core<0.7,>=0.6.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # axor-daemon
16
+
17
+ [![CI](https://github.com/Bucha11/axor-daemon/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Bucha11/axor-daemon/actions/workflows/ci.yml)
18
+ [![PyPI](https://img.shields.io/pypi/v/axor-daemon?cacheSeconds=300)](https://pypi.org/project/axor-daemon/)
19
+ [![Python](https://img.shields.io/pypi/pyversions/axor-daemon?cacheSeconds=300)](https://pypi.org/project/axor-daemon/)
20
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
21
+
22
+ **Process-isolated capability executor for [axor-core](https://github.com/Bucha11/axor-core).**
23
+
24
+ axor-core governs what agents are *allowed* to do. axor-daemon enforces it from *outside the agent process*.
25
+
26
+ ---
27
+
28
+ ## The Problem with Library-Only Governance
29
+
30
+ When governance runs as a library in the same process as the agent, the enforcement boundary is Python-level. A compromised dependency, a monkey-patched import, or a hostile extension can bypass `CapabilityExecutor` without touching the governance logic at all.
31
+
32
+ axor-daemon moves tool execution across a process boundary:
33
+
34
+ ```
35
+ Agent process AxorDaemon process
36
+ ──────────────────────────── ─────────────────────────────────
37
+ GovernedSession DaemonServer (mode 0600 socket)
38
+ IntentLoop DaemonEnforcer
39
+ DaemonCapabilityClient ──────► operator_policy (ceiling)
40
+ (no tool impls here) socket path normalization
41
+ ◄────── exec timeout per handler
42
+ approved result | DENIED
43
+ ```
44
+
45
+ Tool implementations live only in the daemon. The agent process cannot call them directly — it has no code to do so. The Unix socket is the only path, and it is only accessible to the process owner.
46
+
47
+ ---
48
+
49
+ ## Enforcement Model
50
+
51
+ Every tool call passes independent checks in the daemon:
52
+
53
+ **1. Socket access** — the socket is created `0600`. Only the owner process may connect. Any other local process is rejected by the OS before the handshake begins.
54
+
55
+ **2. Protocol version** — the handshake validates `PROTOCOL_VERSION` on both sides. A version mismatch is rejected immediately with an explicit error, not a silent protocol confusion.
56
+
57
+ **3. Operator ceiling** — `operator_policy` is set at daemon startup by the operator and never modified per-connection. The daemon derives allowed tools from it independently — it does not trust the client's claim.
58
+
59
+ **4. Client ceiling** — `allowed_tools` reported by the client's `GovernedSession`. Both the operator ceiling and the client ceiling must approve the tool. The client can only narrow below the operator ceiling, never escalate above it.
60
+
61
+ **5. Arg normalization** — path-like args (`path`, `file`, `target`, etc.) are normalized with `os.path.normpath` by the daemon independently before being passed to any handler. A `../` traversal sequence in a client-supplied path cannot reach a handler unnormalized.
62
+
63
+ **6. Exec timeout** — every handler execution is bounded by `exec_timeout` (default: 60s). A handler that exceeds the timeout returns `DENIED` — it does not hang the daemon session.
64
+
65
+ ```
66
+ Client sends: tool="bash", allowed_tools=["bash", "read"]
67
+
68
+ Operator policy = focused_readonly (allow_bash=False)
69
+ → DENIED operator ceiling "bash not permitted by operator policy"
70
+
71
+ Client sends: tool="read", args={"path": "../../etc/passwd"}, allowed_tools=["read"]
72
+ Operator policy = focused_readonly (allow_read=True)
73
+ → daemon normalizes path → "/etc/passwd"
74
+ → handler receives normalized args, never raw client string
75
+
76
+ Client sends: tool="read", allowed_tools=[] ← excluded read
77
+ → DENIED session ceiling "read not in session allowed_tools"
78
+ ```
79
+
80
+ A client that sends an inflated `allowed_tools` list or crafted path args cannot bypass or escalate — both checks are evaluated daemon-side on independently derived state.
81
+
82
+ ---
83
+
84
+ ## Installation
85
+
86
+ ```bash
87
+ pip install axor-daemon
88
+ ```
89
+
90
+ Requires `axor-core >= 0.5.0, < 0.6`. Zero additional dependencies — stdlib `asyncio` only.
91
+
92
+ ---
93
+
94
+ ## Quick Start
95
+
96
+ **1. Start the daemon**
97
+
98
+ ```bash
99
+ axor-daemon start --policy focused_generative
100
+ ```
101
+
102
+ The daemon loads the operator policy, creates `~/.axor/daemon.sock` with permissions `0600`, and begins accepting connections.
103
+
104
+ **2. Use `DaemonCapabilityClient` instead of `CapabilityExecutor`**
105
+
106
+ ```python
107
+ from axor_core.capability.daemon_client import DaemonCapabilityClient
108
+ import axor_claude
109
+
110
+ session = axor_claude.make_session(
111
+ api_key="sk-ant-...",
112
+ capability_executor=DaemonCapabilityClient(
113
+ socket_path="~/.axor/daemon.sock",
114
+ mode="production",
115
+ ),
116
+ )
117
+
118
+ result = await session.run("Write tests for the auth module")
119
+ ```
120
+
121
+ No other changes. `DaemonCapabilityClient` exposes the same interface as `CapabilityExecutor`.
122
+
123
+ ---
124
+
125
+ ## Operator Policies
126
+
127
+ The operator policy is the capability ceiling. Clients cannot exceed it.
128
+
129
+ ```bash
130
+ axor-daemon start --policy focused_readonly # read + search only, no writes
131
+ axor-daemon start --policy focused_generative # read + write, no bash (default)
132
+ axor-daemon start --policy focused_mutative # read + write + bash
133
+ axor-daemon start --policy moderate_mutative # broad context, bash, shallow children
134
+ axor-daemon start --policy expansive # full capability surface
135
+ ```
136
+
137
+ | Policy | read | write | bash | search | children |
138
+ |--------|------|-------|------|--------|----------|
139
+ | `focused_readonly` | ✓ | — | — | ✓ | — |
140
+ | `focused_generative` | ✓ | ✓ | — | ✓ | — |
141
+ | `focused_mutative` | ✓ | ✓ | ✓ | ✓ | — |
142
+ | `moderate_mutative` | ✓ | ✓ | ✓ | ✓ | shallow |
143
+ | `expansive` | ✓ | ✓ | ✓ | ✓ | ✓ |
144
+
145
+ ---
146
+
147
+ ## CLI Reference
148
+
149
+ ```
150
+ axor-daemon start [options]
151
+
152
+ --socket PATH Unix socket path (default: ~/.axor/daemon.sock)
153
+ --policy NAME Operator policy ceiling (default: focused_generative)
154
+ --log-level LEVEL DEBUG | INFO | WARNING | ERROR (default: INFO)
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Wire Protocol
160
+
161
+ Communication is length-prefixed JSON over a Unix domain socket. Every message carries a protocol version field `"v"`. Version mismatches are rejected at handshake — there is no silent fallback.
162
+
163
+ ```
164
+ Client → {"v": 1, "type": "hello", "mode": "production"}
165
+ Server → {"v": 1, "type": "ready"}
166
+
167
+ Client → {"v": 1, "type": "tool_call", "call_id": "a1b2c3", "tool": "read",
168
+ "args": {"path": "auth.py"}, "allowed_tools": ["read", "search"]}
169
+ Server → {"v": 1, "type": "tool_result", "call_id": "a1b2c3",
170
+ "decision": "approved", "result": "...", "denial_reason": null}
171
+
172
+ Client → {"v": 1, "type": "bye"}
173
+ ```
174
+
175
+ **Framing:** 4-byte big-endian unsigned int (payload length) + JSON bytes. Maximum message size: 8 MB.
176
+
177
+ **Backpressure:** the server enforces a maximum of 64 concurrent connections. Connections beyond this limit receive an immediate `rejected` response. Each connection has a `30s` read timeout — a stalled client does not hold a session indefinitely.
178
+
179
+ One connection per session. If the connection is lost mid-session, `DaemonCapabilityClient` raises `DaemonUnavailableError` — fail-closed by design.
180
+
181
+ ---
182
+
183
+ ## Fail-Closed Guarantee
184
+
185
+ If the daemon is unreachable, `DaemonCapabilityClient.execute()` raises `DaemonUnavailableError`. Execution stops. It never silently falls back to direct tool execution.
186
+
187
+ ```python
188
+ from axor_core.errors.exceptions import DaemonUnavailableError
189
+
190
+ try:
191
+ result = await session.run("audit the auth module")
192
+ except DaemonUnavailableError as e:
193
+ # daemon not running — do not proceed
194
+ raise
195
+ ```
196
+
197
+ ---
198
+
199
+ ## Registering Tool Handlers
200
+
201
+ Tool handlers live in the daemon, not in the client. Extend the daemon at startup:
202
+
203
+ ```python
204
+ from axor_daemon.enforcer import DaemonEnforcer
205
+ from axor_daemon.server import DaemonServer
206
+
207
+ enforcer = DaemonEnforcer(
208
+ operator_policy=operator_policy,
209
+ exec_timeout=30.0, # seconds per handler call, default 60
210
+ handlers={
211
+ "read": MyReadHandler(),
212
+ "write": MyWriteHandler(),
213
+ "bash": MyBashHandler(),
214
+ "search": MySearchHandler(),
215
+ },
216
+ )
217
+ server = DaemonServer(enforcer=enforcer)
218
+ await server.start("~/.axor/daemon.sock")
219
+ await server.serve_forever()
220
+ ```
221
+
222
+ `axor-claude` ships ready-made handlers for Claude tool use. Register them with the daemon at startup — not with the session.
223
+
224
+ ---
225
+
226
+ ## Known Limitations
227
+
228
+ **Socket access is OS-level, not cryptographic.** Any process running as the same OS user may connect to the socket. For multi-user or container environments, run the agent in a separate OS user or apply additional access controls (e.g., systemd socket activation with `User=`).
229
+
230
+ **Path normalization is not an allowlist.** `os.path.normpath` resolves `../` sequences so handlers always receive clean paths. It does not enforce which paths are allowed — that remains the handler's or operator's responsibility. Use `CapabilityLease` with `allowed_paths` for path allowlists.
231
+
232
+ **Exec timeout kills slow handlers, not hanging syscalls.** `asyncio.wait_for` cancels the coroutine. If a handler blocks on a non-async call (e.g., a synchronous subprocess), the cancel will not interrupt it immediately. Use `asyncio.create_subprocess_exec` for shell commands inside handlers.
233
+
234
+ **Trace is client-side.** Audit traces are written by the agent process, not the daemon. A compromised worker could suppress or mutate trace writes. Daemon-side audit logging is planned for a future release.
235
+
236
+ For stronger guarantees, combine axor-daemon with OS-level sandboxing (seccomp, Landlock, container isolation) to restrict what the agent process can do even beyond the governance boundary. That is Level 2.
237
+
238
+ ---
239
+
240
+ ## Requirements
241
+
242
+ - Python 3.11+
243
+ - [`axor-core`](https://github.com/Bucha11/axor-core) >= 0.5.0
244
+ - No additional dependencies — stdlib `asyncio` only
245
+
246
+ ---
247
+
248
+ ## Ecosystem
249
+
250
+ | Package | Role |
251
+ |---------|------|
252
+ | [`axor-core`](https://github.com/Bucha11/axor-core) | Governance kernel — defines the contracts axor-daemon implements |
253
+ | [`axor-daemon`](https://github.com/Bucha11/axor-daemon) | Process-isolated capability executor — this package |
254
+ | [`axor-claude`](https://github.com/Bucha11/axor-claude) | Claude / Claude Code adapter — provides tool handlers |
255
+ | [`axor-cli`](https://github.com/Bucha11/axor-cli) | Governed terminal runtime |
256
+ | [`axor-memory-sqlite`](https://github.com/Bucha11/axor-memory-sqlite) | Cross-session memory (SQLite) |
257
+ | [`axor-classifier-simple`](https://github.com/Bucha11/axor-classifier-simple) | ML task signal derivation (optional) |
258
+ | [`axor-benchmarks`](https://github.com/Bucha11/axor-benchmarks) | Governance proof layer |
259
+
260
+ ---
261
+
262
+ ## License
263
+
264
+ MIT
@@ -0,0 +1,9 @@
1
+ axor_daemon/__init__.py,sha256=pIZrTV1zHikxix0rPa1eXbBKDT_iEl-v_-pHuYZ0koM,161
2
+ axor_daemon/__main__.py,sha256=rgLiSnjx5060ApZUTsb13YqRMkry02fT5D-pey4JsuU,6525
3
+ axor_daemon/_version.py,sha256=o4M0FB8h3-i2HR6VrTYRcbISOl18U5daSiDJDYfL5rY,536
4
+ axor_daemon/enforcer.py,sha256=WU-j6DFHwV4XWDB8DeCCj-zw7uM52FLROQp1DVrYq44,5288
5
+ axor_daemon/server.py,sha256=KorBhOZI6jT-PtcQMpMBJpu943LJb1n5RHSpOP2XDno,6593
6
+ axor_daemon-0.2.0.dist-info/METADATA,sha256=GHIfwBm_qq8nzsDGjpo-yzmRkSxGvD0JrVSyOgapAd8,11223
7
+ axor_daemon-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ axor_daemon-0.2.0.dist-info/entry_points.txt,sha256=eIj2g1gVZ2C1ZlmG1gSJhiGF1cjZOdXyy_VOIT9uOvw,58
9
+ axor_daemon-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ axor-daemon = axor_daemon.__main__:main