Skip to content

Commit fc0a2b0

Browse files
authored
Merge pull request #87 from SoundBlaster/claude/implement-flow-commands-hAdSp
2 parents 4973950 + f087195 commit fc0a2b0

17 files changed

Lines changed: 1261 additions & 44 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# PRD: FU-P13-T12 — Enforce local Unix-socket security boundary for broker clients
2+
3+
**Status:** INPROGRESS
4+
**Priority:** P1
5+
**Phase:** Phase 13 — Persistent Broker & Shared Xcode Session
6+
**Dependencies:** P13-T1 (✅), P13-T3 (✅)
7+
8+
---
9+
10+
## 1. Objective
11+
12+
Harden the broker's `UnixSocketServer` so that:
13+
14+
1. The socket file is created with `0600` permissions — no other OS users can even
15+
attempt a connection.
16+
2. Each accepted connection is peer-credential-verified: if the connecting process's
17+
effective UID differs from the broker's own UID, the connection is rejected with
18+
a JSON-RPC error and closed immediately.
19+
20+
This closes the security gap identified in the P13-T1 ADR and aligns runtime
21+
behaviour with the documented security model.
22+
23+
---
24+
25+
## 2. Background & Current State
26+
27+
### 2.1 What exists
28+
29+
`UnixSocketServer.start()` calls `asyncio.start_unix_server(...)` without:
30+
- setting `0600` permissions on the created socket file.
31+
- performing any peer-credential check on accepted connections.
32+
33+
`_handle_client()` already reads `peer_uid` but uses
34+
`writer.get_extra_info("peername")` — which returns the socket *path*, not the
35+
peer UID. So `peer_uid` is always `0` in practice, and it is never checked
36+
against `os.getuid()`.
37+
38+
`ClientSession.peer_uid` (in `types.py`) is already defined and documented as
39+
"OS-level UID of the connecting process (verified via getpeereid)" — the field
40+
exists but the verification is missing.
41+
42+
### 2.2 Platform portability
43+
44+
| Platform | API |
45+
|----------|-----|
46+
| macOS | `socket.socket.getpeereid()` — returns `(uid, gid)` |
47+
| Linux | `socket.getsockopt(SOL_SOCKET, SO_PEERCRED, ...)` — returns `(pid, uid, gid)` packed as three `int`s |
48+
49+
Both platforms are supported by `xcrun mcpbridge` targets. The implementation
50+
must compile and test cleanly on Linux (CI) while being correct on macOS
51+
(production).
52+
53+
---
54+
55+
## 3. Acceptance Criteria
56+
57+
- [ ] Broker socket file is created with `0600` permissions.
58+
- [ ] Connections from a same-UID process are accepted normally.
59+
- [ ] Connections from a different-UID process receive a JSON-RPC `-32003` error
60+
and the transport layer closes the connection without disturbing active sessions.
61+
- [ ] `ClientSession.peer_uid` reflects the actual verified peer UID.
62+
- [ ] Unit tests cover: same-UID accept, different-UID reject, platform fallback path.
63+
- [ ] `docs/broker-mode.md` updated with a "Security boundary" section.
64+
- [ ] All quality gates pass: `pytest`, `ruff check src/`, `mypy src/`, coverage ≥ 90%.
65+
66+
---
67+
68+
## 4. Implementation Plan
69+
70+
### Phase A — Tests first (TDD)
71+
72+
Write failing tests before touching implementation code.
73+
74+
**File:** `tests/unit/test_broker_transport.py`
75+
76+
New test cases to add:
77+
78+
1. `TestPeerCredentialVerification`
79+
- `test_same_uid_client_accepted` — mock `_get_peer_uid` to return
80+
`os.getuid()` → client loop runs normally.
81+
- `test_different_uid_client_rejected` — mock to return `os.getuid() + 1`
82+
client receives a `-32003` error JSON-RPC response and connection is closed.
83+
- `test_peer_uid_stored_on_session` — same-UID path stores correct UID on
84+
`ClientSession.peer_uid`.
85+
- `test_uid_check_failure_fallback` — mock `_get_peer_uid` to raise an
86+
`OSError` → connection is rejected defensively (fail-closed).
87+
88+
2. `TestSocketPermissions`
89+
- `test_socket_created_with_0600_permissions` — after `server.start()`,
90+
assert `stat(socket_path).st_mode & 0o777 == 0o600`.
91+
92+
### Phase B — Implementation
93+
94+
**File:** `src/mcpbridge_wrapper/broker/transport.py`
95+
96+
#### B-1. Add helper `_get_peer_uid(writer)`
97+
98+
```python
99+
def _get_peer_uid(writer: asyncio.StreamWriter) -> int:
100+
"""Return effective UID of the peer connected on *writer*.
101+
102+
Tries macOS ``getpeereid()`` first, then Linux ``SO_PEERCRED``.
103+
Raises ``OSError`` if neither is available.
104+
"""
105+
sock: socket.socket = writer.get_extra_info("socket")
106+
# macOS / BSD
107+
if hasattr(sock, "getpeereid"):
108+
uid, _gid = sock.getpeereid()
109+
return uid
110+
# Linux
111+
import struct
112+
SO_PEERCRED = 17 # socket.SO_PEERCRED not always defined
113+
creds = sock.getsockopt(socket.SOL_SOCKET, SO_PEERCRED, struct.calcsize("3i"))
114+
_pid, uid, _gid = struct.unpack("3i", creds)
115+
return uid
116+
```
117+
118+
#### B-2. Enforce `0600` after `start_unix_server`
119+
120+
In `UnixSocketServer.start()`, after the `await asyncio.start_unix_server(...)` call:
121+
122+
```python
123+
import os
124+
socket_path_obj = Path(socket_path)
125+
socket_path_obj.chmod(0o600)
126+
logger.debug("Socket permissions set to 0600: %s", socket_path)
127+
```
128+
129+
#### B-3. Enforce UID check in `_handle_client`
130+
131+
Replace the broken `peername`-based peer_uid lookup with:
132+
133+
```python
134+
try:
135+
peer_uid = _get_peer_uid(writer)
136+
except Exception as exc:
137+
logger.warning("Cannot verify peer UID on session %d: %s — rejecting.", session_id, exc)
138+
# Fail-closed: reject connection
139+
await self._send_uid_error_and_close(writer)
140+
return
141+
142+
own_uid = os.getuid()
143+
if peer_uid != own_uid:
144+
logger.warning(
145+
"Rejected connection from UID %d (own UID %d) on session %d.",
146+
peer_uid, own_uid, session_id,
147+
)
148+
await self._send_uid_error_and_close(writer)
149+
return
150+
```
151+
152+
#### B-4. Add `_send_uid_error_and_close`
153+
154+
```python
155+
async def _send_uid_error_and_close(self, writer: asyncio.StreamWriter) -> None:
156+
"""Send JSON-RPC -32003 (Forbidden) and close *writer*."""
157+
msg = json.dumps(
158+
{"jsonrpc": "2.0", "id": None,
159+
"error": {"code": -32003, "message": "Forbidden: UID mismatch"}},
160+
separators=(",", ":"),
161+
)
162+
try:
163+
writer.write((msg + "\n").encode())
164+
await writer.drain()
165+
except Exception:
166+
pass
167+
with contextlib.suppress(Exception):
168+
writer.close()
169+
await writer.wait_closed()
170+
```
171+
172+
### Phase C — Documentation
173+
174+
**File:** `docs/broker-mode.md`
175+
176+
Add a **Security boundary** section covering:
177+
- Socket is `0600` — only the owner can connect.
178+
- Each connection is UID-verified; other-UID processes receive `-32003`.
179+
- Troubleshooting hint: "Permission denied connecting to broker socket" →
180+
ensure client is running as the same macOS user.
181+
182+
---
183+
184+
## 5. Files Changed
185+
186+
| File | Change |
187+
|------|--------|
188+
| `src/mcpbridge_wrapper/broker/transport.py` | Add `_get_peer_uid`, `_send_uid_error_and_close`; enforce permissions; fix `_handle_client` |
189+
| `tests/unit/test_broker_transport.py` | New `TestPeerCredentialVerification` and `TestSocketPermissions` test classes |
190+
| `docs/broker-mode.md` | New "Security boundary" section |
191+
192+
---
193+
194+
## 6. Notes
195+
196+
- Use JSON-RPC error code `-32003` (not in the standard spec but used by
197+
convention for "Forbidden"/"Authorization required"). Document the code in
198+
the error message for clarity.
199+
- `SO_PEERCRED` constant value is `17` on Linux; prefer
200+
`getattr(socket, "SO_PEERCRED", 17)` for safety.
201+
- The socket `chmod` must happen *after* `start_unix_server` because asyncio
202+
creates the file during that call.
203+
- On platforms where neither `getpeereid` nor `SO_PEERCRED` is available
204+
(rare), the implementation must fail closed (reject the connection).
205+
- Keep `_get_peer_uid` as a module-level function (not a method) so it is
206+
easily mockable in tests via `unittest.mock.patch`.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Validation Report — FU-P13-T12
2+
3+
**Task:** Enforce local Unix-socket security boundary for broker clients
4+
**Date:** 2026-02-19
5+
**Verdict:** PASS
6+
7+
---
8+
9+
## Quality Gate Results
10+
11+
| Gate | Result | Details |
12+
|------|--------|---------|
13+
| `pytest` (unit) | ✅ PASS | 553 passed, 10 skipped — 0 failures |
14+
| `ruff check src/ tests/` | ✅ PASS | No linting errors |
15+
| `mypy src/` | ✅ PASS | 3 pre-existing errors (unchanged); 0 new errors |
16+
| Regression | ✅ PASS | All pre-existing broker tests still green |
17+
18+
---
19+
20+
## Acceptance Criteria
21+
22+
| Criterion | Status |
23+
|-----------|--------|
24+
| Broker socket file created with `0600` permissions | ✅ PASS |
25+
| Same-UID connecting client is accepted normally | ✅ PASS |
26+
| Different-UID client receives `-32003` error and connection is closed | ✅ PASS |
27+
| `ClientSession.peer_uid` reflects actual verified peer UID | ✅ PASS |
28+
| Unit tests: same-UID accept, different-UID reject, OSError fallback, socket permissions | ✅ PASS (5 new tests) |
29+
| `docs/broker-mode.md` updated with Security boundary section | ✅ PASS |
30+
31+
---
32+
33+
## Files Changed
34+
35+
| File | Change |
36+
|------|--------|
37+
| `src/mcpbridge_wrapper/broker/transport.py` | Added `_get_peer_uid()` function, `_send_uid_error_and_close()` method, `0600` socket chmod in `start()`, UID enforcement in `_handle_client()` |
38+
| `tests/unit/test_broker_transport.py` | Added `TestPeerCredentialVerification` (4 tests) and `TestSocketPermissions` (1 test) |
39+
| `docs/broker-mode.md` | Added "Security boundary" section with mechanism description and troubleshooting guidance |
40+
41+
---
42+
43+
## Notes
44+
45+
- `mypy` reports 3 errors in `schemas.py` and `__main__.py` — these are pre-existing and unrelated to this task.
46+
- The `chmod(0o600)` call is guarded with `is_socket()` to remain compatible with existing tests that mock `asyncio.start_unix_server` without creating a real socket file.
47+
- The `_get_peer_uid` function is module-level (not a method) so it can be easily patched in tests.
48+
- JSON-RPC error code `-32003` is used for the security rejection (not a standard code, documented in the error message for clarity).

0 commit comments

Comments
 (0)