axor-daemon 0.2.0__tar.gz
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-0.2.0/.github/workflows/ci.yml +81 -0
- axor_daemon-0.2.0/.gitignore +8 -0
- axor_daemon-0.2.0/PKG-INFO +264 -0
- axor_daemon-0.2.0/README.md +250 -0
- axor_daemon-0.2.0/axor_daemon/__init__.py +4 -0
- axor_daemon-0.2.0/axor_daemon/__main__.py +205 -0
- axor_daemon-0.2.0/axor_daemon/_version.py +16 -0
- axor_daemon-0.2.0/axor_daemon/enforcer.py +143 -0
- axor_daemon-0.2.0/axor_daemon/server.py +179 -0
- axor_daemon-0.2.0/pyproject.toml +33 -0
- axor_daemon-0.2.0/tests/__init__.py +0 -0
- axor_daemon-0.2.0/tests/test_client_server.py +211 -0
- axor_daemon-0.2.0/tests/test_enforcer.py +267 -0
- axor_daemon-0.2.0/tests/test_version.py +12 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
name: CI/CD
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ["v*.*.*"]
|
|
7
|
+
pull_request:
|
|
8
|
+
branches: [main]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
name: Test (Python ${{ matrix.python-version }})
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ["3.11", "3.12"]
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: ${{ matrix.python-version }}
|
|
24
|
+
cache: pip
|
|
25
|
+
|
|
26
|
+
- name: Install
|
|
27
|
+
run: |
|
|
28
|
+
pip install -e ".[dev]"
|
|
29
|
+
|
|
30
|
+
- name: Run tests
|
|
31
|
+
run: pytest tests/ -v --tb=short
|
|
32
|
+
|
|
33
|
+
publish:
|
|
34
|
+
name: Publish to PyPI
|
|
35
|
+
needs: test
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
38
|
+
environment: pypi
|
|
39
|
+
|
|
40
|
+
permissions:
|
|
41
|
+
id-token: write # for trusted publishing
|
|
42
|
+
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
|
|
46
|
+
- uses: actions/setup-python@v5
|
|
47
|
+
with:
|
|
48
|
+
python-version: "3.12"
|
|
49
|
+
|
|
50
|
+
- name: Verify tag matches package version
|
|
51
|
+
run: |
|
|
52
|
+
python - << 'EOF'
|
|
53
|
+
import pathlib
|
|
54
|
+
import re
|
|
55
|
+
import sys
|
|
56
|
+
import tomllib
|
|
57
|
+
|
|
58
|
+
ref = "${{ github.ref_name }}"
|
|
59
|
+
m = re.fullmatch(r"v(\d+\.\d+\.\d+)", ref)
|
|
60
|
+
if not m:
|
|
61
|
+
print(f"Tag {ref!r} must match vX.Y.Z")
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
tag_version = m.group(1)
|
|
65
|
+
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8"))
|
|
66
|
+
pkg_version = data["project"]["version"]
|
|
67
|
+
|
|
68
|
+
if tag_version != pkg_version:
|
|
69
|
+
print(f"Version mismatch: tag={tag_version}, pyproject={pkg_version}")
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
|
|
72
|
+
print(f"Version check passed: {pkg_version}")
|
|
73
|
+
EOF
|
|
74
|
+
|
|
75
|
+
- name: Build
|
|
76
|
+
run: |
|
|
77
|
+
pip install hatchling build
|
|
78
|
+
python -m build
|
|
79
|
+
|
|
80
|
+
- name: Publish to PyPI
|
|
81
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -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,250 @@
|
|
|
1
|
+
# axor-daemon
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Bucha11/axor-daemon/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/axor-daemon/)
|
|
5
|
+
[](https://pypi.org/project/axor-daemon/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
**Process-isolated capability executor for [axor-core](https://github.com/Bucha11/axor-core).**
|
|
9
|
+
|
|
10
|
+
axor-core governs what agents are *allowed* to do. axor-daemon enforces it from *outside the agent process*.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## The Problem with Library-Only Governance
|
|
15
|
+
|
|
16
|
+
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.
|
|
17
|
+
|
|
18
|
+
axor-daemon moves tool execution across a process boundary:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
Agent process AxorDaemon process
|
|
22
|
+
──────────────────────────── ─────────────────────────────────
|
|
23
|
+
GovernedSession DaemonServer (mode 0600 socket)
|
|
24
|
+
IntentLoop DaemonEnforcer
|
|
25
|
+
DaemonCapabilityClient ──────► operator_policy (ceiling)
|
|
26
|
+
(no tool impls here) socket path normalization
|
|
27
|
+
◄────── exec timeout per handler
|
|
28
|
+
approved result | DENIED
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Enforcement Model
|
|
36
|
+
|
|
37
|
+
Every tool call passes independent checks in the daemon:
|
|
38
|
+
|
|
39
|
+
**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.
|
|
40
|
+
|
|
41
|
+
**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.
|
|
42
|
+
|
|
43
|
+
**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.
|
|
44
|
+
|
|
45
|
+
**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.
|
|
46
|
+
|
|
47
|
+
**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.
|
|
48
|
+
|
|
49
|
+
**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.
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
Client sends: tool="bash", allowed_tools=["bash", "read"]
|
|
53
|
+
|
|
54
|
+
Operator policy = focused_readonly (allow_bash=False)
|
|
55
|
+
→ DENIED operator ceiling "bash not permitted by operator policy"
|
|
56
|
+
|
|
57
|
+
Client sends: tool="read", args={"path": "../../etc/passwd"}, allowed_tools=["read"]
|
|
58
|
+
Operator policy = focused_readonly (allow_read=True)
|
|
59
|
+
→ daemon normalizes path → "/etc/passwd"
|
|
60
|
+
→ handler receives normalized args, never raw client string
|
|
61
|
+
|
|
62
|
+
Client sends: tool="read", allowed_tools=[] ← excluded read
|
|
63
|
+
→ DENIED session ceiling "read not in session allowed_tools"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
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.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install axor-daemon
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Requires `axor-core >= 0.5.0, < 0.6`. Zero additional dependencies — stdlib `asyncio` only.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Quick Start
|
|
81
|
+
|
|
82
|
+
**1. Start the daemon**
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
axor-daemon start --policy focused_generative
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The daemon loads the operator policy, creates `~/.axor/daemon.sock` with permissions `0600`, and begins accepting connections.
|
|
89
|
+
|
|
90
|
+
**2. Use `DaemonCapabilityClient` instead of `CapabilityExecutor`**
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from axor_core.capability.daemon_client import DaemonCapabilityClient
|
|
94
|
+
import axor_claude
|
|
95
|
+
|
|
96
|
+
session = axor_claude.make_session(
|
|
97
|
+
api_key="sk-ant-...",
|
|
98
|
+
capability_executor=DaemonCapabilityClient(
|
|
99
|
+
socket_path="~/.axor/daemon.sock",
|
|
100
|
+
mode="production",
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
result = await session.run("Write tests for the auth module")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
No other changes. `DaemonCapabilityClient` exposes the same interface as `CapabilityExecutor`.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Operator Policies
|
|
112
|
+
|
|
113
|
+
The operator policy is the capability ceiling. Clients cannot exceed it.
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
axor-daemon start --policy focused_readonly # read + search only, no writes
|
|
117
|
+
axor-daemon start --policy focused_generative # read + write, no bash (default)
|
|
118
|
+
axor-daemon start --policy focused_mutative # read + write + bash
|
|
119
|
+
axor-daemon start --policy moderate_mutative # broad context, bash, shallow children
|
|
120
|
+
axor-daemon start --policy expansive # full capability surface
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| Policy | read | write | bash | search | children |
|
|
124
|
+
|--------|------|-------|------|--------|----------|
|
|
125
|
+
| `focused_readonly` | ✓ | — | — | ✓ | — |
|
|
126
|
+
| `focused_generative` | ✓ | ✓ | — | ✓ | — |
|
|
127
|
+
| `focused_mutative` | ✓ | ✓ | ✓ | ✓ | — |
|
|
128
|
+
| `moderate_mutative` | ✓ | ✓ | ✓ | ✓ | shallow |
|
|
129
|
+
| `expansive` | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## CLI Reference
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
axor-daemon start [options]
|
|
137
|
+
|
|
138
|
+
--socket PATH Unix socket path (default: ~/.axor/daemon.sock)
|
|
139
|
+
--policy NAME Operator policy ceiling (default: focused_generative)
|
|
140
|
+
--log-level LEVEL DEBUG | INFO | WARNING | ERROR (default: INFO)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Wire Protocol
|
|
146
|
+
|
|
147
|
+
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.
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
Client → {"v": 1, "type": "hello", "mode": "production"}
|
|
151
|
+
Server → {"v": 1, "type": "ready"}
|
|
152
|
+
|
|
153
|
+
Client → {"v": 1, "type": "tool_call", "call_id": "a1b2c3", "tool": "read",
|
|
154
|
+
"args": {"path": "auth.py"}, "allowed_tools": ["read", "search"]}
|
|
155
|
+
Server → {"v": 1, "type": "tool_result", "call_id": "a1b2c3",
|
|
156
|
+
"decision": "approved", "result": "...", "denial_reason": null}
|
|
157
|
+
|
|
158
|
+
Client → {"v": 1, "type": "bye"}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Framing:** 4-byte big-endian unsigned int (payload length) + JSON bytes. Maximum message size: 8 MB.
|
|
162
|
+
|
|
163
|
+
**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.
|
|
164
|
+
|
|
165
|
+
One connection per session. If the connection is lost mid-session, `DaemonCapabilityClient` raises `DaemonUnavailableError` — fail-closed by design.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Fail-Closed Guarantee
|
|
170
|
+
|
|
171
|
+
If the daemon is unreachable, `DaemonCapabilityClient.execute()` raises `DaemonUnavailableError`. Execution stops. It never silently falls back to direct tool execution.
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from axor_core.errors.exceptions import DaemonUnavailableError
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
result = await session.run("audit the auth module")
|
|
178
|
+
except DaemonUnavailableError as e:
|
|
179
|
+
# daemon not running — do not proceed
|
|
180
|
+
raise
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Registering Tool Handlers
|
|
186
|
+
|
|
187
|
+
Tool handlers live in the daemon, not in the client. Extend the daemon at startup:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from axor_daemon.enforcer import DaemonEnforcer
|
|
191
|
+
from axor_daemon.server import DaemonServer
|
|
192
|
+
|
|
193
|
+
enforcer = DaemonEnforcer(
|
|
194
|
+
operator_policy=operator_policy,
|
|
195
|
+
exec_timeout=30.0, # seconds per handler call, default 60
|
|
196
|
+
handlers={
|
|
197
|
+
"read": MyReadHandler(),
|
|
198
|
+
"write": MyWriteHandler(),
|
|
199
|
+
"bash": MyBashHandler(),
|
|
200
|
+
"search": MySearchHandler(),
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
server = DaemonServer(enforcer=enforcer)
|
|
204
|
+
await server.start("~/.axor/daemon.sock")
|
|
205
|
+
await server.serve_forever()
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`axor-claude` ships ready-made handlers for Claude tool use. Register them with the daemon at startup — not with the session.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Known Limitations
|
|
213
|
+
|
|
214
|
+
**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=`).
|
|
215
|
+
|
|
216
|
+
**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.
|
|
217
|
+
|
|
218
|
+
**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.
|
|
219
|
+
|
|
220
|
+
**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.
|
|
221
|
+
|
|
222
|
+
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.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Requirements
|
|
227
|
+
|
|
228
|
+
- Python 3.11+
|
|
229
|
+
- [`axor-core`](https://github.com/Bucha11/axor-core) >= 0.5.0
|
|
230
|
+
- No additional dependencies — stdlib `asyncio` only
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Ecosystem
|
|
235
|
+
|
|
236
|
+
| Package | Role |
|
|
237
|
+
|---------|------|
|
|
238
|
+
| [`axor-core`](https://github.com/Bucha11/axor-core) | Governance kernel — defines the contracts axor-daemon implements |
|
|
239
|
+
| [`axor-daemon`](https://github.com/Bucha11/axor-daemon) | Process-isolated capability executor — this package |
|
|
240
|
+
| [`axor-claude`](https://github.com/Bucha11/axor-claude) | Claude / Claude Code adapter — provides tool handlers |
|
|
241
|
+
| [`axor-cli`](https://github.com/Bucha11/axor-cli) | Governed terminal runtime |
|
|
242
|
+
| [`axor-memory-sqlite`](https://github.com/Bucha11/axor-memory-sqlite) | Cross-session memory (SQLite) |
|
|
243
|
+
| [`axor-classifier-simple`](https://github.com/Bucha11/axor-classifier-simple) | ML task signal derivation (optional) |
|
|
244
|
+
| [`axor-benchmarks`](https://github.com/Bucha11/axor-benchmarks) | Governance proof layer |
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
MIT
|