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.
- axor_daemon/__init__.py +4 -0
- axor_daemon/__main__.py +205 -0
- axor_daemon/_version.py +16 -0
- axor_daemon/enforcer.py +143 -0
- axor_daemon/server.py +179 -0
- axor_daemon-0.2.0.dist-info/METADATA +264 -0
- axor_daemon-0.2.0.dist-info/RECORD +9 -0
- axor_daemon-0.2.0.dist-info/WHEEL +4 -0
- axor_daemon-0.2.0.dist-info/entry_points.txt +2 -0
axor_daemon/__init__.py
ADDED
axor_daemon/__main__.py
ADDED
|
@@ -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()
|
axor_daemon/_version.py
ADDED
|
@@ -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"
|
axor_daemon/enforcer.py
ADDED
|
@@ -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
|
+
[](https://github.com/Bucha11/axor-daemon/actions/workflows/ci.yml)
|
|
18
|
+
[](https://pypi.org/project/axor-daemon/)
|
|
19
|
+
[](https://pypi.org/project/axor-daemon/)
|
|
20
|
+
[](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,,
|