lockstock-guard 1.0.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,39 @@
1
+ """
2
+ LockStock Guard - Enterprise Secrets Daemon
3
+
4
+ This package provides the "Secure Enclave in Software" pattern for enterprise
5
+ AI agent deployments. The daemon holds decrypted secrets in memory and serves
6
+ them via Unix socket IPC, ensuring agent applications never have direct access
7
+ to the vault file.
8
+
9
+ Architecture:
10
+ liberty-user: Owns the vault, runs the daemon
11
+ agent-user: Runs application code, connects via socket
12
+
13
+ Components:
14
+ - daemon: The LockStock Guard daemon (lockstock-guard start)
15
+ - client: Client library for agent applications
16
+
17
+ Usage:
18
+ # Start daemon (as liberty-user)
19
+ lockstock-guard start --vault /var/lib/liberty
20
+
21
+ # Agent code (as agent-user)
22
+ from lockstock_guard import client
23
+ secret = client.get("DATABASE_URL")
24
+ """
25
+
26
+ __version__ = "1.0.0"
27
+
28
+ from .client import LibertyClient, get, list_keys, is_available, connect
29
+ from .daemon import LibertyDaemon, SecretCache
30
+
31
+ __all__ = [
32
+ "LibertyClient",
33
+ "LibertyDaemon",
34
+ "SecretCache",
35
+ "get",
36
+ "list_keys",
37
+ "is_available",
38
+ "connect",
39
+ ]
lockstock_guard/cli.py ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ LockStock Guard CLI - Enterprise Secrets Daemon
4
+
5
+ This is the command-line interface for the LockStock Guard daemon,
6
+ which implements the "Secure Enclave in Software" pattern.
7
+
8
+ Usage:
9
+ lockstock-guard start [--vault PATH] [--socket PATH] [--foreground]
10
+ lockstock-guard stop [--socket PATH]
11
+ lockstock-guard status [--socket PATH]
12
+ """
13
+
14
+ import argparse
15
+ import sys
16
+ import os
17
+
18
+ from .daemon import LibertyDaemon
19
+
20
+ # Default paths for enterprise deployment
21
+ DEFAULT_SOCKET_PATH = "/var/run/liberty/liberty.sock"
22
+ DEFAULT_VAULT_PATH = "/var/lib/liberty"
23
+ DEFAULT_PID_FILE = "/var/run/liberty/liberty.pid"
24
+
25
+
26
+ def main():
27
+ """Main CLI entry point."""
28
+ parser = argparse.ArgumentParser(
29
+ description='LockStock Guard - Enterprise Secrets Daemon',
30
+ formatter_class=argparse.RawDescriptionHelpFormatter,
31
+ epilog="""
32
+ Architecture:
33
+ This daemon implements the "Secure Enclave in Software" pattern.
34
+ It holds secrets in memory and serves them via Unix socket IPC.
35
+ Agent applications connect to the socket to request secrets.
36
+
37
+ Security:
38
+ - Daemon runs as privileged user (liberty-user) with vault access
39
+ - Agent apps run as unprivileged user with socket access only
40
+ - Secrets never touch agent process memory until explicitly requested
41
+ - Compromised agent cannot dump the vault
42
+
43
+ Examples:
44
+ # Start daemon (as liberty-user or root)
45
+ lockstock-guard start
46
+
47
+ # Start with custom paths
48
+ lockstock-guard start --vault ~/.liberty --socket /tmp/liberty.sock
49
+
50
+ # Start in foreground for debugging
51
+ lockstock-guard start --foreground
52
+
53
+ # Stop daemon
54
+ lockstock-guard stop
55
+
56
+ # Check status
57
+ lockstock-guard status
58
+
59
+ Agent Usage:
60
+ from lockstock_guard import client
61
+
62
+ # Connect to daemon
63
+ secret = client.get("DATABASE_URL", socket_path="/var/run/liberty/liberty.sock")
64
+ """
65
+ )
66
+
67
+ parser.add_argument(
68
+ "command",
69
+ choices=["start", "stop", "status"],
70
+ help="Command to run"
71
+ )
72
+ parser.add_argument(
73
+ "--socket",
74
+ default=DEFAULT_SOCKET_PATH,
75
+ help=f"Socket path (default: {DEFAULT_SOCKET_PATH})"
76
+ )
77
+ parser.add_argument(
78
+ "--vault",
79
+ default=DEFAULT_VAULT_PATH,
80
+ help=f"Vault path (default: {DEFAULT_VAULT_PATH})"
81
+ )
82
+ parser.add_argument(
83
+ "--group",
84
+ default="agents",
85
+ help="Socket group for agent access (default: agents)"
86
+ )
87
+ parser.add_argument(
88
+ "--foreground", "-f",
89
+ action="store_true",
90
+ help="Run in foreground (don't daemonize)"
91
+ )
92
+ parser.add_argument(
93
+ "--pid",
94
+ default=None,
95
+ help="PID file path (default: alongside socket)"
96
+ )
97
+
98
+ args = parser.parse_args()
99
+
100
+ # Expand paths
101
+ socket_path = os.path.expanduser(args.socket)
102
+ vault_path = os.path.expanduser(args.vault)
103
+
104
+ # Derive PID path from socket path if not specified
105
+ pid_file = args.pid
106
+ if pid_file is None:
107
+ from pathlib import Path
108
+ socket_dir = Path(socket_path).parent
109
+ pid_file = str(socket_dir / "liberty.pid")
110
+
111
+ daemon = LibertyDaemon(
112
+ socket_path=socket_path,
113
+ vault_path=vault_path,
114
+ socket_group=args.group,
115
+ pid_file=pid_file,
116
+ )
117
+
118
+ if args.command == "start":
119
+ print(f"Starting LockStock Guard daemon...")
120
+ print(f" Vault: {vault_path}")
121
+ print(f" Socket: {socket_path}")
122
+ daemon.start(foreground=args.foreground)
123
+
124
+ elif args.command == "stop":
125
+ daemon.stop()
126
+
127
+ elif args.command == "status":
128
+ if daemon._is_running():
129
+ pid = daemon.pid_file.read_text().strip()
130
+ print(f"LockStock Guard is running (PID: {pid})")
131
+ print(f" Socket: {daemon.socket_path}")
132
+ print(f" Vault: {daemon.vault_path}")
133
+ else:
134
+ print("LockStock Guard is not running")
135
+ sys.exit(1)
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
@@ -0,0 +1,445 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ LockStock Guard Client - Secure secret retrieval for agent applications.
4
+
5
+ This client connects to the LockStock Guard daemon via Unix socket to retrieve secrets.
6
+ The agent application NEVER has direct access to the vault file.
7
+
8
+ Security Model:
9
+ - Daemon runs as liberty-user, owns the vault
10
+ - Agent runs as app-user, can only access socket
11
+ - Secrets are retrieved on-demand, not bulk-loaded
12
+
13
+ Usage:
14
+ from lockstock_guard import client
15
+
16
+ # Get a single secret
17
+ db_url = client.get("DATABASE_URL")
18
+
19
+ # Get multiple secrets
20
+ secrets = client.get_many(["API_KEY", "DATABASE_URL"])
21
+
22
+ # List available keys
23
+ keys = client.list_keys()
24
+
25
+ # Use context manager for connection pooling
26
+ with client.connect() as conn:
27
+ api_key = conn.get("API_KEY")
28
+ db_url = conn.get("DATABASE_URL")
29
+ """
30
+
31
+ import os
32
+ import socket
33
+ import struct
34
+ import json
35
+ from pathlib import Path
36
+ from typing import Optional, Dict, List
37
+ from contextlib import contextmanager
38
+
39
+
40
+ # Default socket path (matches daemon)
41
+ DEFAULT_SOCKET_PATH = "/var/run/liberty/liberty.sock"
42
+
43
+ # Protocol constants (must match daemon)
44
+ PROTOCOL_VERSION = 1
45
+ MAX_MESSAGE_SIZE = 64 * 1024
46
+
47
+ # Message types
48
+ MSG_GET_SECRET = 0x01
49
+ MSG_SECRET_RESPONSE = 0x02
50
+ MSG_LIST_KEYS = 0x03
51
+ MSG_KEYS_RESPONSE = 0x04
52
+ MSG_PING = 0x05
53
+ MSG_PONG = 0x06
54
+ MSG_ERROR = 0xFF
55
+
56
+
57
+ class LibertyError(Exception):
58
+ """Base exception for Liberty client errors."""
59
+ pass
60
+
61
+
62
+ class DaemonNotRunning(LibertyError):
63
+ """Daemon is not running or socket not accessible."""
64
+ pass
65
+
66
+
67
+ class SecretNotFound(LibertyError):
68
+ """Requested secret does not exist."""
69
+ pass
70
+
71
+
72
+ class AccessDenied(LibertyError):
73
+ """Access to the requested secret was denied."""
74
+ pass
75
+
76
+
77
+ class LibertyClient:
78
+ """
79
+ Client for communicating with Liberty daemon.
80
+
81
+ Usage:
82
+ client = LibertyClient()
83
+ secret = client.get("DATABASE_URL")
84
+ client.close()
85
+
86
+ Or with context manager:
87
+ with LibertyClient() as client:
88
+ secret = client.get("DATABASE_URL")
89
+ """
90
+
91
+ def __init__(self, socket_path: str = None):
92
+ """
93
+ Initialize client.
94
+
95
+ Args:
96
+ socket_path: Path to daemon socket (default: from LIBERTY_SOCKET env or /var/run/liberty/liberty.sock)
97
+ """
98
+ self.socket_path = socket_path or os.getenv("LIBERTY_SOCKET", DEFAULT_SOCKET_PATH)
99
+ self._socket = None
100
+
101
+ def connect(self):
102
+ """Connect to daemon."""
103
+ if self._socket is not None:
104
+ return # Already connected
105
+
106
+ socket_path = Path(self.socket_path)
107
+
108
+ if not socket_path.exists():
109
+ raise DaemonNotRunning(f"Socket not found: {self.socket_path}")
110
+
111
+ try:
112
+ self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
113
+ self._socket.connect(str(socket_path))
114
+ except (ConnectionRefusedError, PermissionError) as e:
115
+ self._socket = None
116
+ raise DaemonNotRunning(f"Cannot connect to daemon: {e}")
117
+
118
+ def close(self):
119
+ """Close connection to daemon."""
120
+ if self._socket:
121
+ try:
122
+ self._socket.close()
123
+ except Exception:
124
+ pass
125
+ self._socket = None
126
+
127
+ def __enter__(self):
128
+ """Context manager entry."""
129
+ self.connect()
130
+ return self
131
+
132
+ def __exit__(self, exc_type, exc_val, exc_tb):
133
+ """Context manager exit."""
134
+ self.close()
135
+ return False
136
+
137
+ def get(self, key: str) -> str:
138
+ """
139
+ Get a secret value.
140
+
141
+ Args:
142
+ key: Secret key name
143
+
144
+ Returns:
145
+ Secret value
146
+
147
+ Raises:
148
+ SecretNotFound: If key doesn't exist
149
+ AccessDenied: If access is denied
150
+ DaemonNotRunning: If daemon is not available
151
+ """
152
+ self.connect()
153
+
154
+ # Send request
155
+ payload = key.encode("utf-8")
156
+ message = self._encode_message(MSG_GET_SECRET, payload)
157
+ self._socket.sendall(message)
158
+
159
+ # Read response
160
+ msg_type, response = self._read_response()
161
+
162
+ if msg_type == MSG_SECRET_RESPONSE:
163
+ data = json.loads(response.decode("utf-8"))
164
+ return data["value"]
165
+
166
+ elif msg_type == MSG_ERROR:
167
+ error = json.loads(response.decode("utf-8"))
168
+ error_type = error.get("error")
169
+
170
+ if error_type == "not_found":
171
+ raise SecretNotFound(f"Secret not found: {key}")
172
+ elif error_type == "access_denied":
173
+ raise AccessDenied(f"Access denied: {key}")
174
+ else:
175
+ raise LibertyError(f"Daemon error: {error}")
176
+
177
+ else:
178
+ raise LibertyError(f"Unexpected response type: {msg_type}")
179
+
180
+ def get_many(self, keys: List[str]) -> Dict[str, str]:
181
+ """
182
+ Get multiple secrets.
183
+
184
+ Args:
185
+ keys: List of secret key names
186
+
187
+ Returns:
188
+ Dict mapping keys to values (missing keys are omitted)
189
+ """
190
+ result = {}
191
+ for key in keys:
192
+ try:
193
+ result[key] = self.get(key)
194
+ except SecretNotFound:
195
+ pass # Omit missing keys
196
+ return result
197
+
198
+ def list_keys(self) -> List[str]:
199
+ """
200
+ List available secret keys.
201
+
202
+ Returns:
203
+ List of key names
204
+ """
205
+ self.connect()
206
+
207
+ message = self._encode_message(MSG_LIST_KEYS, b"")
208
+ self._socket.sendall(message)
209
+
210
+ msg_type, response = self._read_response()
211
+
212
+ if msg_type == MSG_KEYS_RESPONSE:
213
+ data = json.loads(response.decode("utf-8"))
214
+ return data.get("keys", [])
215
+
216
+ elif msg_type == MSG_ERROR:
217
+ error = json.loads(response.decode("utf-8"))
218
+ raise LibertyError(f"Daemon error: {error}")
219
+
220
+ else:
221
+ raise LibertyError(f"Unexpected response type: {msg_type}")
222
+
223
+ def ping(self) -> bool:
224
+ """
225
+ Check if daemon is responding.
226
+
227
+ Returns:
228
+ True if daemon responded, False otherwise
229
+ """
230
+ try:
231
+ self.connect()
232
+ message = self._encode_message(MSG_PING, b"")
233
+ self._socket.sendall(message)
234
+
235
+ msg_type, response = self._read_response()
236
+ return msg_type == MSG_PONG
237
+
238
+ except Exception:
239
+ self.close() # Reset connection on error
240
+ return False
241
+
242
+ def _encode_message(self, msg_type: int, payload: bytes) -> bytes:
243
+ """Encode message with header."""
244
+ header = struct.pack("!BBL", PROTOCOL_VERSION, msg_type, len(payload))
245
+ return header + payload
246
+
247
+ def _read_response(self) -> tuple:
248
+ """Read response from daemon. Returns (msg_type, payload)."""
249
+ # Read header
250
+ header = self._recv_exact(6)
251
+ if not header:
252
+ self.close() # Reset connection state
253
+ raise DaemonNotRunning("Connection closed by daemon")
254
+
255
+ version, msg_type, length = struct.unpack("!BBL", header)
256
+
257
+ if version != PROTOCOL_VERSION:
258
+ raise LibertyError(f"Protocol version mismatch: {version}")
259
+
260
+ if length > MAX_MESSAGE_SIZE:
261
+ raise LibertyError(f"Message too large: {length}")
262
+
263
+ # Read payload
264
+ payload = self._recv_exact(length) if length > 0 else b""
265
+
266
+ return msg_type, payload
267
+
268
+ def _recv_exact(self, n: int) -> bytes:
269
+ """Receive exactly n bytes."""
270
+ data = b""
271
+ while len(data) < n:
272
+ chunk = self._socket.recv(n - len(data))
273
+ if not chunk:
274
+ return None
275
+ data += chunk
276
+ return data
277
+
278
+
279
+ # Module-level singleton for convenience
280
+ _default_client: Optional[LibertyClient] = None
281
+
282
+
283
+ def _get_client() -> LibertyClient:
284
+ """Get or create default client."""
285
+ global _default_client
286
+ if _default_client is None:
287
+ _default_client = LibertyClient()
288
+ return _default_client
289
+
290
+
291
+ def get(key: str) -> str:
292
+ """
293
+ Get a secret from the Liberty daemon.
294
+
295
+ This is the primary API for agent applications.
296
+
297
+ Args:
298
+ key: Secret key name (e.g., "DATABASE_URL", "API_KEY")
299
+
300
+ Returns:
301
+ Secret value
302
+
303
+ Raises:
304
+ SecretNotFound: If key doesn't exist
305
+ AccessDenied: If access is denied
306
+ DaemonNotRunning: If daemon is not available
307
+
308
+ Example:
309
+ import liberty_client
310
+ db_url = liberty_client.get("DATABASE_URL")
311
+ """
312
+ return _get_client().get(key)
313
+
314
+
315
+ def get_many(keys: List[str]) -> Dict[str, str]:
316
+ """
317
+ Get multiple secrets from the Liberty daemon.
318
+
319
+ Args:
320
+ keys: List of secret key names
321
+
322
+ Returns:
323
+ Dict mapping keys to values (missing keys are omitted)
324
+
325
+ Example:
326
+ secrets = liberty_client.get_many(["API_KEY", "DATABASE_URL"])
327
+ """
328
+ return _get_client().get_many(keys)
329
+
330
+
331
+ def list_keys() -> List[str]:
332
+ """
333
+ List available secret keys.
334
+
335
+ Returns:
336
+ List of key names
337
+ """
338
+ return _get_client().list_keys()
339
+
340
+
341
+ def is_available() -> bool:
342
+ """
343
+ Check if Liberty daemon is available.
344
+
345
+ Returns:
346
+ True if daemon is running and accessible
347
+ """
348
+ return _get_client().ping()
349
+
350
+
351
+ @contextmanager
352
+ def connect(socket_path: str = None):
353
+ """
354
+ Context manager for explicit connection management.
355
+
356
+ Useful when making many requests to avoid reconnection overhead.
357
+
358
+ Args:
359
+ socket_path: Optional custom socket path
360
+
361
+ Example:
362
+ with liberty_client.connect() as client:
363
+ api_key = client.get("API_KEY")
364
+ db_url = client.get("DATABASE_URL")
365
+ """
366
+ client = LibertyClient(socket_path)
367
+ try:
368
+ client.connect()
369
+ yield client
370
+ finally:
371
+ client.close()
372
+
373
+
374
+ # Environment variable helper
375
+ def env(key: str, default: str = None) -> Optional[str]:
376
+ """
377
+ Get a secret, falling back to environment variable.
378
+
379
+ This provides a migration path from env-based config to Liberty.
380
+
381
+ Args:
382
+ key: Secret/env var name
383
+ default: Default value if neither source has the key
384
+
385
+ Returns:
386
+ Secret value, env value, or default
387
+
388
+ Example:
389
+ # Works with Liberty daemon or falls back to $DATABASE_URL
390
+ db_url = liberty_client.env("DATABASE_URL")
391
+ """
392
+ try:
393
+ if is_available():
394
+ return get(key)
395
+ except (SecretNotFound, AccessDenied):
396
+ pass
397
+
398
+ return os.getenv(key, default)
399
+
400
+
401
+ if __name__ == "__main__":
402
+ # Simple CLI for testing
403
+ import sys
404
+
405
+ if len(sys.argv) < 2:
406
+ print("Usage: liberty_client.py <command> [args]")
407
+ print("Commands:")
408
+ print(" get <key> - Get a secret")
409
+ print(" list - List keys")
410
+ print(" ping - Check daemon")
411
+ sys.exit(1)
412
+
413
+ cmd = sys.argv[1]
414
+
415
+ try:
416
+ if cmd == "get" and len(sys.argv) >= 3:
417
+ key = sys.argv[2]
418
+ value = get(key)
419
+ print(value)
420
+
421
+ elif cmd == "list":
422
+ keys = list_keys()
423
+ for k in keys:
424
+ print(k)
425
+
426
+ elif cmd == "ping":
427
+ if is_available():
428
+ print("Daemon is running")
429
+ else:
430
+ print("Daemon is not available")
431
+ sys.exit(1)
432
+
433
+ else:
434
+ print(f"Unknown command: {cmd}")
435
+ sys.exit(1)
436
+
437
+ except DaemonNotRunning as e:
438
+ print(f"Error: {e}", file=sys.stderr)
439
+ sys.exit(1)
440
+ except SecretNotFound as e:
441
+ print(f"Error: {e}", file=sys.stderr)
442
+ sys.exit(1)
443
+ except AccessDenied as e:
444
+ print(f"Error: {e}", file=sys.stderr)
445
+ sys.exit(1)
@@ -0,0 +1,549 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ LockStock Guard Daemon - Secure Enclave in Software
4
+
5
+ This daemon holds decrypted secrets in memory and serves them via Unix socket.
6
+ Agent applications NEVER have direct access to the vault file.
7
+
8
+ Security Architecture:
9
+ liberty-user: Owns the vault, runs the daemon
10
+ agent-user: Runs application code, connects via socket
11
+
12
+ Filesystem Layout:
13
+ /var/lib/liberty/secrets.enc (liberty-user:liberty-user, mode 600)
14
+ /var/run/liberty/liberty.sock (liberty-user:agents, mode 660)
15
+
16
+ Usage:
17
+ # Start daemon (as liberty-user)
18
+ lockstock-guard start
19
+
20
+ # Agent code (as agent-user)
21
+ from lockstock_guard import client
22
+ secret = client.get("DATABASE_URL")
23
+ """
24
+
25
+ import os
26
+ import sys
27
+ import json
28
+ import socket
29
+ import signal
30
+ import struct
31
+ import threading
32
+ import logging
33
+ import hashlib
34
+ import grp
35
+ import pwd
36
+ from pathlib import Path
37
+ from datetime import datetime, timezone
38
+ from typing import Dict, Optional, Any
39
+
40
+ # Import vault from main liberty module
41
+ try:
42
+ from liberty import SecretVault, HardwareFingerprint
43
+ except ImportError:
44
+ # Fallback for standalone testing
45
+ print("Warning: liberty module not found, using mock vault")
46
+ SecretVault = None
47
+ HardwareFingerprint = None
48
+
49
+ logging.basicConfig(
50
+ level=logging.INFO,
51
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
52
+ )
53
+ logger = logging.getLogger("lockstock-guard")
54
+
55
+
56
+ # Default paths
57
+ DEFAULT_SOCKET_PATH = "/var/run/liberty/liberty.sock"
58
+ DEFAULT_VAULT_PATH = "/var/lib/liberty"
59
+ DEFAULT_PID_FILE = "/var/run/liberty/liberty.pid"
60
+
61
+ # Protocol constants
62
+ PROTOCOL_VERSION = 1
63
+ MAX_MESSAGE_SIZE = 64 * 1024 # 64KB max message
64
+
65
+
66
+ class DaemonProtocol:
67
+ """Wire protocol for daemon communication."""
68
+
69
+ # Message types
70
+ MSG_GET_SECRET = 0x01
71
+ MSG_SECRET_RESPONSE = 0x02
72
+ MSG_LIST_KEYS = 0x03
73
+ MSG_KEYS_RESPONSE = 0x04
74
+ MSG_PING = 0x05
75
+ MSG_PONG = 0x06
76
+ MSG_ERROR = 0xFF
77
+
78
+ @staticmethod
79
+ def encode_message(msg_type: int, payload: bytes) -> bytes:
80
+ """Encode message with length prefix and type."""
81
+ # Format: [version:1][type:1][length:4][payload:N]
82
+ header = struct.pack("!BBL", PROTOCOL_VERSION, msg_type, len(payload))
83
+ return header + payload
84
+
85
+ @staticmethod
86
+ def decode_header(data: bytes) -> tuple:
87
+ """Decode message header. Returns (version, msg_type, length)."""
88
+ if len(data) < 6:
89
+ raise ValueError("Invalid header: too short")
90
+ version, msg_type, length = struct.unpack("!BBL", data[:6])
91
+ if version != PROTOCOL_VERSION:
92
+ raise ValueError(f"Unsupported protocol version: {version}")
93
+ if length > MAX_MESSAGE_SIZE:
94
+ raise ValueError(f"Message too large: {length}")
95
+ return version, msg_type, length
96
+
97
+
98
+ class SecretCache:
99
+ """
100
+ In-memory secret cache with access control.
101
+
102
+ Secrets are decrypted once at daemon startup and held in memory.
103
+ Never written to disk in plaintext.
104
+ """
105
+
106
+ def __init__(self):
107
+ self._secrets: Dict[str, str] = {}
108
+ self._access_log: list = []
109
+ self._lock = threading.RLock()
110
+
111
+ def load_from_vault(self, vault: "SecretVault") -> int:
112
+ """Load all secrets from vault into memory."""
113
+ with self._lock:
114
+ try:
115
+ raw_secrets = vault._load_secrets()
116
+ # Extract just the values (vault stores metadata too)
117
+ self._secrets = {}
118
+ for key, data in raw_secrets.items():
119
+ if isinstance(data, dict):
120
+ self._secrets[key] = data.get('value', str(data))
121
+ else:
122
+ self._secrets[key] = data
123
+ logger.info(f"Loaded {len(self._secrets)} secrets into cache")
124
+ return len(self._secrets)
125
+ except Exception as e:
126
+ logger.error(f"Failed to load vault: {e}")
127
+ return 0
128
+
129
+ def get(self, key: str, client_info: dict = None) -> Optional[str]:
130
+ """Get a secret, logging access."""
131
+ with self._lock:
132
+ value = self._secrets.get(key)
133
+ self._log_access(key, client_info, found=value is not None)
134
+ return value
135
+
136
+ def list_keys(self) -> list:
137
+ """List all secret keys (not values)."""
138
+ with self._lock:
139
+ return list(self._secrets.keys())
140
+
141
+ def _log_access(self, key: str, client_info: dict, found: bool):
142
+ """Log secret access for audit."""
143
+ entry = {
144
+ "timestamp": datetime.now(timezone.utc).isoformat(),
145
+ "key": key,
146
+ "found": found,
147
+ "client": client_info or {},
148
+ }
149
+ self._access_log.append(entry)
150
+ # Keep last 1000 entries
151
+ if len(self._access_log) > 1000:
152
+ self._access_log = self._access_log[-1000:]
153
+
154
+
155
+ class ClientHandler(threading.Thread):
156
+ """Handle a single client connection."""
157
+
158
+ def __init__(self, conn: socket.socket, addr, cache: SecretCache, allowed_keys: set = None):
159
+ super().__init__(daemon=True)
160
+ self.conn = conn
161
+ self.addr = addr
162
+ self.cache = cache
163
+ self.allowed_keys = allowed_keys # If set, restrict to these keys
164
+ self._running = True
165
+
166
+ def run(self):
167
+ """Handle client requests."""
168
+ try:
169
+ # Get peer credentials (Unix socket only)
170
+ client_info = self._get_peer_credentials()
171
+ logger.info(f"Client connected: {client_info}")
172
+
173
+ while self._running:
174
+ # Read header
175
+ header = self._recv_exact(6)
176
+ if not header:
177
+ break
178
+
179
+ version, msg_type, length = DaemonProtocol.decode_header(header)
180
+
181
+ # Read payload
182
+ payload = self._recv_exact(length) if length > 0 else b""
183
+
184
+ # Handle message
185
+ response = self._handle_message(msg_type, payload, client_info)
186
+ self.conn.sendall(response)
187
+
188
+ except Exception as e:
189
+ logger.error(f"Client handler error: {e}")
190
+ finally:
191
+ self.conn.close()
192
+ logger.info(f"Client disconnected")
193
+
194
+ def _recv_exact(self, n: int) -> bytes:
195
+ """Receive exactly n bytes."""
196
+ data = b""
197
+ while len(data) < n:
198
+ chunk = self.conn.recv(n - len(data))
199
+ if not chunk:
200
+ return None
201
+ data += chunk
202
+ return data
203
+
204
+ def _get_peer_credentials(self) -> dict:
205
+ """Get peer credentials from Unix socket."""
206
+ try:
207
+ creds = self.conn.getsockopt(
208
+ socket.SOL_SOCKET,
209
+ socket.SO_PEERCRED,
210
+ struct.calcsize("3i")
211
+ )
212
+ pid, uid, gid = struct.unpack("3i", creds)
213
+ return {
214
+ "pid": pid,
215
+ "uid": uid,
216
+ "gid": gid,
217
+ "user": pwd.getpwuid(uid).pw_name if uid >= 0 else "unknown",
218
+ }
219
+ except Exception:
220
+ return {"pid": 0, "uid": -1, "gid": -1, "user": "unknown"}
221
+
222
+ def _handle_message(self, msg_type: int, payload: bytes, client_info: dict) -> bytes:
223
+ """Handle a single message."""
224
+ try:
225
+ if msg_type == DaemonProtocol.MSG_PING:
226
+ return DaemonProtocol.encode_message(DaemonProtocol.MSG_PONG, b"pong")
227
+
228
+ elif msg_type == DaemonProtocol.MSG_GET_SECRET:
229
+ key = payload.decode("utf-8")
230
+
231
+ # Check access control
232
+ if self.allowed_keys and key not in self.allowed_keys:
233
+ error = json.dumps({"error": "access_denied", "key": key})
234
+ return DaemonProtocol.encode_message(
235
+ DaemonProtocol.MSG_ERROR,
236
+ error.encode("utf-8")
237
+ )
238
+
239
+ value = self.cache.get(key, client_info)
240
+ if value is None:
241
+ error = json.dumps({"error": "not_found", "key": key})
242
+ return DaemonProtocol.encode_message(
243
+ DaemonProtocol.MSG_ERROR,
244
+ error.encode("utf-8")
245
+ )
246
+
247
+ response = json.dumps({"key": key, "value": value})
248
+ return DaemonProtocol.encode_message(
249
+ DaemonProtocol.MSG_SECRET_RESPONSE,
250
+ response.encode("utf-8")
251
+ )
252
+
253
+ elif msg_type == DaemonProtocol.MSG_LIST_KEYS:
254
+ keys = self.cache.list_keys()
255
+ # Filter by allowed keys if restricted
256
+ if self.allowed_keys:
257
+ keys = [k for k in keys if k in self.allowed_keys]
258
+ response = json.dumps({"keys": keys})
259
+ return DaemonProtocol.encode_message(
260
+ DaemonProtocol.MSG_KEYS_RESPONSE,
261
+ response.encode("utf-8")
262
+ )
263
+
264
+ else:
265
+ error = json.dumps({"error": "unknown_message_type", "type": msg_type})
266
+ return DaemonProtocol.encode_message(
267
+ DaemonProtocol.MSG_ERROR,
268
+ error.encode("utf-8")
269
+ )
270
+
271
+ except Exception as e:
272
+ error = json.dumps({"error": "internal_error", "message": str(e)})
273
+ return DaemonProtocol.encode_message(
274
+ DaemonProtocol.MSG_ERROR,
275
+ error.encode("utf-8")
276
+ )
277
+
278
+
279
+ class LibertyDaemon:
280
+ """
281
+ Liberty Daemon - holds secrets in memory, serves via Unix socket.
282
+
283
+ This implements the "Secure Enclave in Software" pattern:
284
+ - Daemon runs as privileged user with vault access
285
+ - Agent apps run as unprivileged user with socket access only
286
+ - Secrets never touch agent process memory until explicitly requested
287
+ """
288
+
289
+ def __init__(
290
+ self,
291
+ socket_path: str = DEFAULT_SOCKET_PATH,
292
+ vault_path: str = DEFAULT_VAULT_PATH,
293
+ pid_file: str = DEFAULT_PID_FILE,
294
+ socket_group: str = "agents",
295
+ ):
296
+ self.socket_path = Path(socket_path)
297
+ self.vault_path = Path(vault_path)
298
+ self.pid_file = Path(pid_file)
299
+ self.socket_group = socket_group
300
+
301
+ self.cache = SecretCache()
302
+ self.server_socket = None
303
+ self._running = False
304
+ self._threads = []
305
+
306
+ def start(self, foreground: bool = False):
307
+ """Start the daemon."""
308
+ # Check if already running
309
+ if self._is_running():
310
+ logger.error("Daemon already running")
311
+ return False
312
+
313
+ # Load secrets into cache
314
+ if not self._load_vault():
315
+ logger.error("Failed to load vault")
316
+ return False
317
+
318
+ # Create socket directory
319
+ self.socket_path.parent.mkdir(parents=True, exist_ok=True)
320
+
321
+ # Remove stale socket
322
+ if self.socket_path.exists():
323
+ self.socket_path.unlink()
324
+
325
+ # Create Unix socket
326
+ self.server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
327
+ self.server_socket.bind(str(self.socket_path))
328
+
329
+ # Set socket permissions: owner + group can connect
330
+ os.chmod(self.socket_path, 0o660)
331
+
332
+ # Set group ownership if specified
333
+ try:
334
+ gid = grp.getgrnam(self.socket_group).gr_gid
335
+ os.chown(self.socket_path, -1, gid)
336
+ except KeyError:
337
+ logger.warning(f"Group '{self.socket_group}' not found, using default")
338
+
339
+ self.server_socket.listen(10)
340
+ logger.info(f"Listening on {self.socket_path}")
341
+
342
+ # Write PID file
343
+ self.pid_file.parent.mkdir(parents=True, exist_ok=True)
344
+ self.pid_file.write_text(str(os.getpid()))
345
+
346
+ # Setup signal handlers (only works in main thread)
347
+ try:
348
+ signal.signal(signal.SIGTERM, self._handle_signal)
349
+ signal.signal(signal.SIGINT, self._handle_signal)
350
+ except ValueError:
351
+ # Not in main thread, skip signal handling
352
+ pass
353
+
354
+ # Daemonize if not foreground
355
+ if not foreground:
356
+ self._daemonize()
357
+
358
+ # Accept connections
359
+ self._running = True
360
+ try:
361
+ while self._running:
362
+ try:
363
+ self.server_socket.settimeout(1.0)
364
+ conn, addr = self.server_socket.accept()
365
+ handler = ClientHandler(conn, addr, self.cache)
366
+ handler.start()
367
+ self._threads.append(handler)
368
+ except socket.timeout:
369
+ continue
370
+ finally:
371
+ self._cleanup()
372
+
373
+ return True
374
+
375
+ def stop(self):
376
+ """Stop the daemon."""
377
+ if not self._is_running():
378
+ logger.info("Daemon not running")
379
+ return
380
+
381
+ # Read PID and send SIGTERM
382
+ try:
383
+ pid = int(self.pid_file.read_text().strip())
384
+ os.kill(pid, signal.SIGTERM)
385
+ logger.info(f"Sent SIGTERM to PID {pid}")
386
+ except (FileNotFoundError, ValueError, ProcessLookupError) as e:
387
+ logger.error(f"Failed to stop daemon: {e}")
388
+
389
+ def _load_vault(self) -> bool:
390
+ """Load vault into memory cache."""
391
+ if SecretVault is None:
392
+ logger.error("SecretVault not available")
393
+ return False
394
+
395
+ vault = SecretVault(str(self.vault_path))
396
+
397
+ if not vault.secrets_file.exists():
398
+ logger.error(f"Vault not found at {self.vault_path}")
399
+ return False
400
+
401
+ count = self.cache.load_from_vault(vault)
402
+
403
+ if count == 0:
404
+ logger.warning("Vault is empty or failed to decrypt")
405
+
406
+ return True
407
+
408
+ def _is_running(self) -> bool:
409
+ """Check if daemon is already running."""
410
+ if not self.pid_file.exists():
411
+ return False
412
+
413
+ try:
414
+ pid = int(self.pid_file.read_text().strip())
415
+ # Check if process exists
416
+ os.kill(pid, 0)
417
+ return True
418
+ except (ValueError, ProcessLookupError):
419
+ # Stale PID file
420
+ self.pid_file.unlink(missing_ok=True)
421
+ return False
422
+
423
+ def _daemonize(self):
424
+ """Fork into background daemon."""
425
+ # First fork
426
+ if os.fork() > 0:
427
+ sys.exit(0)
428
+
429
+ os.setsid()
430
+
431
+ # Second fork
432
+ if os.fork() > 0:
433
+ sys.exit(0)
434
+
435
+ # Redirect stdio
436
+ sys.stdin = open(os.devnull, 'r')
437
+ sys.stdout = open(os.devnull, 'w')
438
+ sys.stderr = open(os.devnull, 'w')
439
+
440
+ def _handle_signal(self, signum, frame):
441
+ """Handle shutdown signals."""
442
+ logger.info(f"Received signal {signum}, shutting down")
443
+ self._running = False
444
+
445
+ def _cleanup(self):
446
+ """Clean up resources."""
447
+ if self.server_socket:
448
+ self.server_socket.close()
449
+
450
+ if self.socket_path.exists():
451
+ self.socket_path.unlink()
452
+
453
+ if self.pid_file.exists():
454
+ self.pid_file.unlink()
455
+
456
+ # Wait for handlers to finish
457
+ for thread in self._threads:
458
+ thread.join(timeout=1.0)
459
+
460
+ logger.info("Daemon stopped")
461
+
462
+
463
+ def main():
464
+ """CLI entry point."""
465
+ import argparse
466
+
467
+ parser = argparse.ArgumentParser(
468
+ description="Liberty Daemon - Secure secret server",
469
+ formatter_class=argparse.RawDescriptionHelpFormatter,
470
+ epilog="""
471
+ Examples:
472
+ # Start daemon (as liberty-user)
473
+ liberty-daemon start
474
+
475
+ # Start in foreground for debugging
476
+ liberty-daemon start --foreground
477
+
478
+ # Stop daemon
479
+ liberty-daemon stop
480
+
481
+ # Check status
482
+ liberty-daemon status
483
+ """
484
+ )
485
+
486
+ parser.add_argument(
487
+ "command",
488
+ choices=["start", "stop", "status"],
489
+ help="Command to run"
490
+ )
491
+ parser.add_argument(
492
+ "--socket",
493
+ default=DEFAULT_SOCKET_PATH,
494
+ help=f"Socket path (default: {DEFAULT_SOCKET_PATH})"
495
+ )
496
+ parser.add_argument(
497
+ "--vault",
498
+ default=DEFAULT_VAULT_PATH,
499
+ help=f"Vault path (default: {DEFAULT_VAULT_PATH})"
500
+ )
501
+ parser.add_argument(
502
+ "--group",
503
+ default="agents",
504
+ help="Socket group for agent access (default: agents)"
505
+ )
506
+ parser.add_argument(
507
+ "--foreground", "-f",
508
+ action="store_true",
509
+ help="Run in foreground (don't daemonize)"
510
+ )
511
+ parser.add_argument(
512
+ "--pid",
513
+ default=None,
514
+ help="PID file path (default: alongside socket)"
515
+ )
516
+
517
+ args = parser.parse_args()
518
+
519
+ # Derive PID path from socket path if not specified
520
+ pid_file = args.pid
521
+ if pid_file is None:
522
+ socket_dir = Path(args.socket).parent
523
+ pid_file = str(socket_dir / "liberty.pid")
524
+
525
+ daemon = LibertyDaemon(
526
+ socket_path=args.socket,
527
+ vault_path=args.vault,
528
+ socket_group=args.group,
529
+ pid_file=pid_file,
530
+ )
531
+
532
+ if args.command == "start":
533
+ daemon.start(foreground=args.foreground)
534
+
535
+ elif args.command == "stop":
536
+ daemon.stop()
537
+
538
+ elif args.command == "status":
539
+ if daemon._is_running():
540
+ pid = daemon.pid_file.read_text().strip()
541
+ print(f"Liberty daemon is running (PID: {pid})")
542
+ print(f"Socket: {daemon.socket_path}")
543
+ else:
544
+ print("Liberty daemon is not running")
545
+ sys.exit(1)
546
+
547
+
548
+ if __name__ == "__main__":
549
+ main()
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.4
2
+ Name: lockstock-guard
3
+ Version: 1.0.0
4
+ Summary: Enterprise secrets daemon for LockStock Protocol - Secure Enclave in Software
5
+ Author-email: D3cipher <contact@d3cipher.ai>
6
+ License: MIT
7
+ Project-URL: Homepage, https://d3cipher.ai
8
+ Project-URL: Documentation, https://d3cipher.ai/docs-architecture.html
9
+ Project-URL: Repository, https://gitlab.com/deciphergit/lockstock
10
+ Project-URL: Issues, https://gitlab.com/deciphergit/lockstock/-/issues
11
+ Keywords: secrets,security,daemon,enterprise,lockstock,ai-agents
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: System :: Systems Administration
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: cryptography>=41.0.0
26
+ Requires-Dist: liberty-secrets>=1.0.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
30
+
31
+ # LockStock Guard
32
+
33
+ Enterprise secrets daemon for LockStock Protocol - "Secure Enclave in Software"
34
+
35
+ ## Overview
36
+
37
+ LockStock Guard provides enterprise-grade secrets management for AI agent deployments. Unlike personal secrets managers, Guard implements user separation where the daemon holds secrets and agent applications request them via IPC.
38
+
39
+ ```
40
+ AGENT SERVER
41
+
42
+ +------------------+ +---------------------------+
43
+ | lockstock-guard | | agent application |
44
+ | (liberty-user) | | (app-user) |
45
+ | | Unix | |
46
+ | - Owns vault | Socket | - Cannot read vault |
47
+ | - Decrypts |<------->| - Requests via IPC |
48
+ | - Serves | | - Gets only what needed |
49
+ +------------------+ +---------------------------+
50
+
51
+ /var/lib/liberty/secrets.enc (liberty:liberty, mode 600)
52
+ /var/run/liberty/liberty.sock (liberty:agents, mode 660)
53
+ ```
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ pip install lockstock-guard
59
+ ```
60
+
61
+ **Requires:** [liberty-secrets](https://pypi.org/project/liberty-secrets/) (installed automatically)
62
+
63
+ ## Quick Start
64
+
65
+ ### 1. Initialize the Enterprise Vault
66
+
67
+ ```bash
68
+ # Create enterprise vault directory (as root or liberty-user)
69
+ sudo mkdir -p /var/lib/liberty /var/run/liberty
70
+ sudo chown liberty:liberty /var/lib/liberty
71
+ sudo chown liberty:agents /var/run/liberty
72
+ sudo chmod 750 /var/lib/liberty /var/run/liberty
73
+
74
+ # Initialize vault (as liberty-user)
75
+ sudo -u liberty liberty --vault /var/lib/liberty init
76
+
77
+ # Add agent secrets
78
+ sudo -u liberty liberty --vault /var/lib/liberty add AGENT_XYZ_SECRET
79
+ ```
80
+
81
+ ### 2. Start the Daemon
82
+
83
+ ```bash
84
+ # Start daemon (as liberty-user or via systemd)
85
+ lockstock-guard start
86
+
87
+ # Or with custom paths
88
+ lockstock-guard start --vault /var/lib/liberty --socket /var/run/liberty/liberty.sock
89
+
90
+ # Check status
91
+ lockstock-guard status
92
+ ```
93
+
94
+ ### 3. Use from Agent Application
95
+
96
+ ```python
97
+ from lockstock_guard import client
98
+
99
+ # Get a secret (connects to daemon via socket)
100
+ secret = client.get("AGENT_XYZ_SECRET")
101
+
102
+ # List available keys
103
+ keys = client.list_keys()
104
+
105
+ # Connection pooling for multiple requests
106
+ with client.connect() as conn:
107
+ key1 = conn.get("API_KEY")
108
+ key2 = conn.get("DATABASE_URL")
109
+ ```
110
+
111
+ ## Systemd Service
112
+
113
+ Install the systemd service for production:
114
+
115
+ ```bash
116
+ sudo cp lockstock-guard.service /etc/systemd/system/
117
+ sudo systemctl daemon-reload
118
+ sudo systemctl enable lockstock-guard
119
+ sudo systemctl start lockstock-guard
120
+ ```
121
+
122
+ ## Security Model
123
+
124
+ | Component | User | Access |
125
+ |-----------|------|--------|
126
+ | Vault file | liberty-user | Read/write (mode 600) |
127
+ | Socket | liberty:agents | Group access (mode 660) |
128
+ | Daemon | liberty-user | Decrypts, serves secrets |
129
+ | Agent app | app-user (in agents group) | Socket only, no vault access |
130
+
131
+ **Key security properties:**
132
+ - Secrets never in environment variables
133
+ - Vault file inaccessible to agent processes
134
+ - Compromised agent cannot dump vault
135
+ - Least privilege - agent gets only requested secrets
136
+
137
+ ## Integration with LockStock MCP
138
+
139
+ The Guard integrates with the LockStock MCP Wallet server:
140
+
141
+ ```python
142
+ # MCP server configuration
143
+ export LIBERTY_SOCKET=/var/run/liberty/liberty.sock
144
+ export LOCKSTOCK_AGENT_ID=agent_xyz
145
+
146
+ # MCP Wallet retrieves secret from Guard daemon
147
+ # Agent never sees the secret directly
148
+ ```
149
+
150
+ ## CLI Reference
151
+
152
+ ```
153
+ lockstock-guard start [OPTIONS]
154
+ --vault PATH Vault directory (default: /var/lib/liberty)
155
+ --socket PATH Socket path (default: /var/run/liberty/liberty.sock)
156
+ --group NAME Socket group (default: agents)
157
+ --foreground Run in foreground (don't daemonize)
158
+
159
+ lockstock-guard stop
160
+ Stop the running daemon
161
+
162
+ lockstock-guard status
163
+ Check if daemon is running
164
+ ```
165
+
166
+ ## License
167
+
168
+ MIT License - See LICENSE file
@@ -0,0 +1,9 @@
1
+ lockstock_guard/__init__.py,sha256=O59Lit8SuvbmTLbDxsnLg7FjNf2hIfdWjpeE0OjDnzo,1055
2
+ lockstock_guard/cli.py,sha256=Oim2VUE_nsQmKv75bpbYsSp47FSZSmJ0DvelIK-XsEA,3938
3
+ lockstock_guard/client.py,sha256=ZSrefFpdn4e9OUwD_Meo6lhY2DVahCA9MxoJ5voeuto,11599
4
+ lockstock_guard/daemon.py,sha256=k4eYI1BvUP2CqlR8JonAMAYJ2v0y23tdnoCcQgZ0gh4,17267
5
+ lockstock_guard-1.0.0.dist-info/METADATA,sha256=ZCPtJ_uXmzugTMIj_EbYf4-GobQwQDKsA2aF0fEMQlA,5103
6
+ lockstock_guard-1.0.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
7
+ lockstock_guard-1.0.0.dist-info/entry_points.txt,sha256=hmhPEKUrxoaYQOi3H2h2l4y55gjWn8RobUD0gxuTyOI,61
8
+ lockstock_guard-1.0.0.dist-info/top_level.txt,sha256=LHLVyMiO08cTiQgfC2r5XJLTAd-TAiX_dFD9CZwceOM,16
9
+ lockstock_guard-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lockstock-guard = lockstock_guard.cli:main
@@ -0,0 +1 @@
1
+ lockstock_guard