delimit-cli 4.1.44 → 4.1.48
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.
- package/CHANGELOG.md +22 -0
- package/bin/delimit-cli.js +365 -30
- package/bin/delimit-setup.js +115 -81
- package/gateway/ai/activate_helpers.py +253 -7
- package/gateway/ai/backends/gateway_core.py +236 -13
- package/gateway/ai/backends/repo_bridge.py +80 -16
- package/gateway/ai/backends/tools_infra.py +49 -32
- package/gateway/ai/checksums.sha256 +6 -0
- package/gateway/ai/continuity.py +462 -0
- package/gateway/ai/deliberation.pyi +53 -0
- package/gateway/ai/governance.pyi +32 -0
- package/gateway/ai/governance_hardening.py +569 -0
- package/gateway/ai/hot_reload.py +445 -0
- package/gateway/ai/inbox_daemon_runner.py +217 -0
- package/gateway/ai/ledger_manager.py +40 -0
- package/gateway/ai/license.py +104 -3
- package/gateway/ai/license_core.py +177 -36
- package/gateway/ai/license_core.pyi +50 -0
- package/gateway/ai/loop_engine.py +786 -22
- package/gateway/ai/reddit_scanner.py +150 -5
- package/gateway/ai/server.py +301 -19
- package/gateway/ai/swarm.py +87 -0
- package/gateway/ai/swarm_infra.py +656 -0
- package/gateway/ai/tweet_corpus_schema.sql +76 -0
- package/gateway/core/diff_engine_v2.py +6 -2
- package/gateway/core/generator_drift.py +242 -0
- package/gateway/core/json_schema_diff.py +375 -0
- package/gateway/core/openapi_version.py +124 -0
- package/gateway/core/spec_detector.py +47 -7
- package/gateway/core/spec_health.py +5 -2
- package/lib/cross-model-hooks.js +31 -17
- package/lib/delimit-template.js +19 -85
- package/package.json +9 -2
- package/scripts/sync-gateway.sh +13 -1
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
"""Swarm shared infrastructure — auth, logging, namespace isolation, directory jailing.
|
|
2
|
+
|
|
3
|
+
LED-274: Phase 1 shared infrastructure for multi-agent orchestration.
|
|
4
|
+
Provides enforcement primitives that the swarm dispatch system can wire into
|
|
5
|
+
agent operations. All classes are lightweight and stateless where possible;
|
|
6
|
+
persistent state lives in ~/.delimit/swarm/ alongside the existing registry.
|
|
7
|
+
|
|
8
|
+
Design:
|
|
9
|
+
- SwarmAuth: venture-scoped token issue/validate for agent identity
|
|
10
|
+
- SwarmLogger: centralized structured logging with venture+agent context
|
|
11
|
+
- VentureNamespace: resolve venture paths and enforce boundaries
|
|
12
|
+
- DirectoryJail: hard enforcement — raises on out-of-scope writes
|
|
13
|
+
- SharedLibs: read-only access registry for common utilities
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict, List, Optional, Set
|
|
24
|
+
|
|
25
|
+
# Re-use the swarm directory for shared state
|
|
26
|
+
SWARM_DIR = Path.home() / ".delimit" / "swarm"
|
|
27
|
+
AUTH_DIR = SWARM_DIR / "auth"
|
|
28
|
+
INFRA_LOG = SWARM_DIR / "infra_log.jsonl"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# =========================================================================
|
|
32
|
+
# SwarmAuth — venture-scoped tokens for agent identity
|
|
33
|
+
# =========================================================================
|
|
34
|
+
|
|
35
|
+
class SwarmAuth:
|
|
36
|
+
"""Issue and validate HMAC-based tokens scoped to a venture+agent pair.
|
|
37
|
+
|
|
38
|
+
Tokens are short-lived (default 1 hour) and tied to a specific venture
|
|
39
|
+
namespace. The signing secret is derived per-venture so a token from
|
|
40
|
+
venture A cannot be used in venture B.
|
|
41
|
+
|
|
42
|
+
Token format (hex): hmac_hex : issued_ts : venture : agent_id
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
TOKEN_FILE = AUTH_DIR / "tokens.json"
|
|
46
|
+
# Default to 1 hour; caller can override
|
|
47
|
+
DEFAULT_TTL = 3600
|
|
48
|
+
|
|
49
|
+
def __init__(self, master_secret: str = ""):
|
|
50
|
+
"""Initialize with a master secret. Falls back to a machine-derived key."""
|
|
51
|
+
self._master = master_secret or self._derive_machine_secret()
|
|
52
|
+
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
# -- public API -------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def issue_token(
|
|
57
|
+
self,
|
|
58
|
+
venture: str,
|
|
59
|
+
agent_id: str,
|
|
60
|
+
ttl: int = 0,
|
|
61
|
+
) -> Dict[str, Any]:
|
|
62
|
+
"""Issue a venture-scoped token for an agent."""
|
|
63
|
+
if not venture or not agent_id:
|
|
64
|
+
return {"error": "venture and agent_id are required"}
|
|
65
|
+
|
|
66
|
+
ttl = ttl or self.DEFAULT_TTL
|
|
67
|
+
issued = int(time.time())
|
|
68
|
+
expires = issued + ttl
|
|
69
|
+
payload = f"{venture}:{agent_id}:{issued}:{expires}"
|
|
70
|
+
sig = self._sign(venture, payload)
|
|
71
|
+
|
|
72
|
+
token = f"{sig}:{issued}:{expires}:{venture}:{agent_id}"
|
|
73
|
+
|
|
74
|
+
# Persist for revocation checks
|
|
75
|
+
self._store_token(token, venture, agent_id, expires)
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
"token": token,
|
|
79
|
+
"venture": venture,
|
|
80
|
+
"agent_id": agent_id,
|
|
81
|
+
"issued_at": issued,
|
|
82
|
+
"expires_at": expires,
|
|
83
|
+
"ttl_seconds": ttl,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
def validate_token(self, token: str) -> Dict[str, Any]:
|
|
87
|
+
"""Validate a token. Returns agent identity or error."""
|
|
88
|
+
parts = token.split(":")
|
|
89
|
+
if len(parts) != 5:
|
|
90
|
+
return {"valid": False, "error": "Malformed token"}
|
|
91
|
+
|
|
92
|
+
sig, issued_str, expires_str, venture, agent_id = parts
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
issued = int(issued_str)
|
|
96
|
+
expires = int(expires_str)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return {"valid": False, "error": "Invalid timestamp in token"}
|
|
99
|
+
|
|
100
|
+
# Expiry check
|
|
101
|
+
now = int(time.time())
|
|
102
|
+
if now > expires:
|
|
103
|
+
return {
|
|
104
|
+
"valid": False,
|
|
105
|
+
"error": "Token expired",
|
|
106
|
+
"expired_at": expires,
|
|
107
|
+
"venture": venture,
|
|
108
|
+
"agent_id": agent_id,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# Signature check
|
|
112
|
+
payload = f"{venture}:{agent_id}:{issued}:{expires}"
|
|
113
|
+
expected_sig = self._sign(venture, payload)
|
|
114
|
+
if not hmac.compare_digest(sig, expected_sig):
|
|
115
|
+
return {"valid": False, "error": "Invalid signature"}
|
|
116
|
+
|
|
117
|
+
# Revocation check
|
|
118
|
+
if self._is_revoked(token):
|
|
119
|
+
return {"valid": False, "error": "Token has been revoked"}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
"valid": True,
|
|
123
|
+
"venture": venture,
|
|
124
|
+
"agent_id": agent_id,
|
|
125
|
+
"issued_at": issued,
|
|
126
|
+
"expires_at": expires,
|
|
127
|
+
"remaining_seconds": expires - now,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
def revoke_token(self, token: str) -> Dict[str, Any]:
|
|
131
|
+
"""Revoke a token so it can no longer be used."""
|
|
132
|
+
tokens = self._load_tokens()
|
|
133
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()[:16]
|
|
134
|
+
if token_hash in tokens:
|
|
135
|
+
tokens[token_hash]["revoked"] = True
|
|
136
|
+
tokens[token_hash]["revoked_at"] = int(time.time())
|
|
137
|
+
self._save_tokens(tokens)
|
|
138
|
+
return {"status": "revoked", "token_hash": token_hash}
|
|
139
|
+
return {"status": "not_found", "token_hash": token_hash}
|
|
140
|
+
|
|
141
|
+
# -- internals --------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _derive_machine_secret() -> str:
|
|
145
|
+
"""Derive a stable secret from machine identity (hostname + uid)."""
|
|
146
|
+
identity = f"{os.uname().nodename}:{os.getuid()}:delimit-swarm-v1"
|
|
147
|
+
return hashlib.sha256(identity.encode()).hexdigest()
|
|
148
|
+
|
|
149
|
+
def _venture_key(self, venture: str) -> bytes:
|
|
150
|
+
"""Derive a per-venture signing key from the master secret."""
|
|
151
|
+
return hashlib.sha256(f"{self._master}:{venture}".encode()).digest()
|
|
152
|
+
|
|
153
|
+
def _sign(self, venture: str, payload: str) -> str:
|
|
154
|
+
key = self._venture_key(venture)
|
|
155
|
+
return hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()[:32]
|
|
156
|
+
|
|
157
|
+
def _load_tokens(self) -> Dict[str, Any]:
|
|
158
|
+
if not self.TOKEN_FILE.exists():
|
|
159
|
+
return {}
|
|
160
|
+
try:
|
|
161
|
+
return json.loads(self.TOKEN_FILE.read_text())
|
|
162
|
+
except (json.JSONDecodeError, OSError):
|
|
163
|
+
return {}
|
|
164
|
+
|
|
165
|
+
def _save_tokens(self, tokens: Dict[str, Any]):
|
|
166
|
+
self.TOKEN_FILE.write_text(json.dumps(tokens, indent=2))
|
|
167
|
+
|
|
168
|
+
def _store_token(self, token: str, venture: str, agent_id: str, expires: int):
|
|
169
|
+
tokens = self._load_tokens()
|
|
170
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()[:16]
|
|
171
|
+
tokens[token_hash] = {
|
|
172
|
+
"venture": venture,
|
|
173
|
+
"agent_id": agent_id,
|
|
174
|
+
"expires": expires,
|
|
175
|
+
"revoked": False,
|
|
176
|
+
}
|
|
177
|
+
# Prune expired tokens while we are here
|
|
178
|
+
now = int(time.time())
|
|
179
|
+
tokens = {k: v for k, v in tokens.items() if v.get("expires", 0) > now or v.get("revoked")}
|
|
180
|
+
self._save_tokens(tokens)
|
|
181
|
+
|
|
182
|
+
def _is_revoked(self, token: str) -> bool:
|
|
183
|
+
tokens = self._load_tokens()
|
|
184
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()[:16]
|
|
185
|
+
entry = tokens.get(token_hash, {})
|
|
186
|
+
return entry.get("revoked", False)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# =========================================================================
|
|
190
|
+
# SwarmLogger — centralized structured logging with venture/agent context
|
|
191
|
+
# =========================================================================
|
|
192
|
+
|
|
193
|
+
class SwarmLogger:
|
|
194
|
+
"""Structured JSON-line logger with venture and agent context tags.
|
|
195
|
+
|
|
196
|
+
All log entries go to a shared JSONL file that the governor can query.
|
|
197
|
+
Also integrates with Python's logging module for standard stderr output.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(self, log_file: Optional[Path] = None):
|
|
201
|
+
self._log_file = log_file or INFRA_LOG
|
|
202
|
+
SWARM_DIR.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
self._py_logger = logging.getLogger("delimit.swarm")
|
|
204
|
+
if not self._py_logger.handlers:
|
|
205
|
+
handler = logging.StreamHandler()
|
|
206
|
+
handler.setFormatter(logging.Formatter(
|
|
207
|
+
"[%(asctime)s] %(levelname)s %(name)s — %(message)s",
|
|
208
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
209
|
+
))
|
|
210
|
+
self._py_logger.addHandler(handler)
|
|
211
|
+
self._py_logger.setLevel(logging.DEBUG)
|
|
212
|
+
|
|
213
|
+
def log(
|
|
214
|
+
self,
|
|
215
|
+
level: str,
|
|
216
|
+
message: str,
|
|
217
|
+
venture: str = "",
|
|
218
|
+
agent_id: str = "",
|
|
219
|
+
action: str = "",
|
|
220
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
221
|
+
) -> Dict[str, Any]:
|
|
222
|
+
"""Write a structured log entry."""
|
|
223
|
+
entry = {
|
|
224
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
225
|
+
"epoch": time.time(),
|
|
226
|
+
"level": level.upper(),
|
|
227
|
+
"message": message,
|
|
228
|
+
"venture": venture,
|
|
229
|
+
"agent_id": agent_id,
|
|
230
|
+
"action": action,
|
|
231
|
+
}
|
|
232
|
+
if extra:
|
|
233
|
+
entry["extra"] = extra
|
|
234
|
+
|
|
235
|
+
# Write to JSONL
|
|
236
|
+
try:
|
|
237
|
+
with open(self._log_file, "a") as f:
|
|
238
|
+
f.write(json.dumps(entry) + "\n")
|
|
239
|
+
except OSError:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
# Mirror to Python logging
|
|
243
|
+
py_level = getattr(logging, level.upper(), logging.INFO)
|
|
244
|
+
ctx = f"[{venture}/{agent_id}]" if venture else ""
|
|
245
|
+
self._py_logger.log(py_level, f"{ctx} {message}")
|
|
246
|
+
|
|
247
|
+
return entry
|
|
248
|
+
|
|
249
|
+
def info(self, message: str, **kwargs) -> Dict[str, Any]:
|
|
250
|
+
return self.log("INFO", message, **kwargs)
|
|
251
|
+
|
|
252
|
+
def warn(self, message: str, **kwargs) -> Dict[str, Any]:
|
|
253
|
+
return self.log("WARNING", message, **kwargs)
|
|
254
|
+
|
|
255
|
+
def error(self, message: str, **kwargs) -> Dict[str, Any]:
|
|
256
|
+
return self.log("ERROR", message, **kwargs)
|
|
257
|
+
|
|
258
|
+
def audit(self, message: str, **kwargs) -> Dict[str, Any]:
|
|
259
|
+
"""Audit-level log entry (always persisted, never filtered)."""
|
|
260
|
+
return self.log("AUDIT", message, **kwargs)
|
|
261
|
+
|
|
262
|
+
def query(
|
|
263
|
+
self,
|
|
264
|
+
venture: str = "",
|
|
265
|
+
agent_id: str = "",
|
|
266
|
+
level: str = "",
|
|
267
|
+
limit: int = 50,
|
|
268
|
+
) -> List[Dict[str, Any]]:
|
|
269
|
+
"""Query recent log entries with optional filters."""
|
|
270
|
+
if not self._log_file.exists():
|
|
271
|
+
return []
|
|
272
|
+
|
|
273
|
+
entries: List[Dict[str, Any]] = []
|
|
274
|
+
try:
|
|
275
|
+
with open(self._log_file) as f:
|
|
276
|
+
for line in f:
|
|
277
|
+
line = line.strip()
|
|
278
|
+
if not line:
|
|
279
|
+
continue
|
|
280
|
+
try:
|
|
281
|
+
entry = json.loads(line)
|
|
282
|
+
except json.JSONDecodeError:
|
|
283
|
+
continue
|
|
284
|
+
if venture and entry.get("venture") != venture:
|
|
285
|
+
continue
|
|
286
|
+
if agent_id and entry.get("agent_id") != agent_id:
|
|
287
|
+
continue
|
|
288
|
+
if level and entry.get("level") != level.upper():
|
|
289
|
+
continue
|
|
290
|
+
entries.append(entry)
|
|
291
|
+
except OSError:
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
# Return most recent first, capped at limit
|
|
295
|
+
return entries[-limit:][::-1]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# =========================================================================
|
|
299
|
+
# VentureNamespace — resolve venture paths and enforce boundaries
|
|
300
|
+
# =========================================================================
|
|
301
|
+
|
|
302
|
+
class VentureNamespace:
|
|
303
|
+
"""Resolve venture-specific paths and enforce namespace boundaries.
|
|
304
|
+
|
|
305
|
+
Each venture gets an isolated directory tree under ~/.delimit/ventures/<ns>/
|
|
306
|
+
plus its registered repo_path. Agents can only operate within their
|
|
307
|
+
venture's resolved paths.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
VENTURES_DIR = Path.home() / ".delimit" / "ventures"
|
|
311
|
+
VENTURES_FILE = SWARM_DIR / "ventures.json"
|
|
312
|
+
|
|
313
|
+
def __init__(self):
|
|
314
|
+
self.VENTURES_DIR.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
|
|
316
|
+
def resolve(self, venture: str) -> Dict[str, Any]:
|
|
317
|
+
"""Resolve all paths for a venture namespace."""
|
|
318
|
+
if not venture:
|
|
319
|
+
return {"error": "venture name is required"}
|
|
320
|
+
|
|
321
|
+
ns = venture.strip().lower().replace(" ", "_").replace("-", "_")
|
|
322
|
+
venture_root = self.VENTURES_DIR / ns
|
|
323
|
+
venture_root.mkdir(parents=True, exist_ok=True)
|
|
324
|
+
|
|
325
|
+
# Also check for a registered repo_path
|
|
326
|
+
repo_path = self._get_repo_path(venture)
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
"venture": venture,
|
|
330
|
+
"namespace": ns,
|
|
331
|
+
"venture_root": str(venture_root),
|
|
332
|
+
"repo_path": repo_path,
|
|
333
|
+
"data_dir": str(venture_root / "data"),
|
|
334
|
+
"logs_dir": str(venture_root / "logs"),
|
|
335
|
+
"tmp_dir": str(venture_root / "tmp"),
|
|
336
|
+
"allowed_roots": self._allowed_roots(ns, repo_path),
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
def is_within_namespace(self, venture: str, target_path: str) -> bool:
|
|
340
|
+
"""Check if a path falls within the venture's namespace."""
|
|
341
|
+
info = self.resolve(venture)
|
|
342
|
+
if "error" in info:
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
target = Path(target_path).resolve()
|
|
346
|
+
for root in info["allowed_roots"]:
|
|
347
|
+
if str(target).startswith(root):
|
|
348
|
+
return True
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
def _get_repo_path(self, venture: str) -> str:
|
|
352
|
+
"""Look up the repo_path from the swarm ventures registry."""
|
|
353
|
+
if not self.VENTURES_FILE.exists():
|
|
354
|
+
return ""
|
|
355
|
+
try:
|
|
356
|
+
ventures = json.loads(self.VENTURES_FILE.read_text())
|
|
357
|
+
name = venture.strip().lower()
|
|
358
|
+
return ventures.get(name, {}).get("repo_path", "")
|
|
359
|
+
except (json.JSONDecodeError, OSError):
|
|
360
|
+
return ""
|
|
361
|
+
|
|
362
|
+
def _allowed_roots(self, ns: str, repo_path: str) -> List[str]:
|
|
363
|
+
"""Build the list of allowed root paths for a namespace."""
|
|
364
|
+
roots = [str(self.VENTURES_DIR / ns)]
|
|
365
|
+
if repo_path:
|
|
366
|
+
roots.append(str(Path(repo_path).resolve()))
|
|
367
|
+
return roots
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# =========================================================================
|
|
371
|
+
# DirectoryJail — enforce that agents only write to their venture dirs
|
|
372
|
+
# =========================================================================
|
|
373
|
+
|
|
374
|
+
class DirectoryJailViolation(Exception):
|
|
375
|
+
"""Raised when an agent attempts to write outside its venture scope."""
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class DirectoryJail:
|
|
380
|
+
"""Enforce write-path restrictions for agents.
|
|
381
|
+
|
|
382
|
+
Validates every write path against the venture's allowed roots.
|
|
383
|
+
Raises DirectoryJailViolation on any violation -- this is hard
|
|
384
|
+
enforcement, not advisory logging.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
def __init__(self, namespace: Optional[VentureNamespace] = None):
|
|
388
|
+
self._ns = namespace or VentureNamespace()
|
|
389
|
+
self._logger = SwarmLogger()
|
|
390
|
+
|
|
391
|
+
def check_write(self, venture: str, agent_id: str, target_path: str) -> bool:
|
|
392
|
+
"""Validate a write path. Returns True if allowed, raises on violation."""
|
|
393
|
+
resolved = Path(target_path).resolve()
|
|
394
|
+
|
|
395
|
+
# Block obvious escapes
|
|
396
|
+
if ".." in str(target_path):
|
|
397
|
+
self._raise_violation(venture, agent_id, target_path, "Path contains '..' traversal")
|
|
398
|
+
|
|
399
|
+
# Check namespace membership
|
|
400
|
+
if not self._ns.is_within_namespace(venture, str(resolved)):
|
|
401
|
+
self._raise_violation(
|
|
402
|
+
venture,
|
|
403
|
+
agent_id,
|
|
404
|
+
target_path,
|
|
405
|
+
f"Path is outside venture '{venture}' namespace",
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Block sensitive system paths regardless of namespace
|
|
409
|
+
blocked_prefixes = ["/etc", "/usr", "/bin", "/sbin", "/boot", "/proc", "/sys"]
|
|
410
|
+
for prefix in blocked_prefixes:
|
|
411
|
+
if str(resolved).startswith(prefix):
|
|
412
|
+
self._raise_violation(
|
|
413
|
+
venture,
|
|
414
|
+
agent_id,
|
|
415
|
+
target_path,
|
|
416
|
+
f"Writes to system path '{prefix}' are always blocked",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
self._logger.info(
|
|
420
|
+
f"Write allowed: {target_path}",
|
|
421
|
+
venture=venture,
|
|
422
|
+
agent_id=agent_id,
|
|
423
|
+
action="jail_check_pass",
|
|
424
|
+
)
|
|
425
|
+
return True
|
|
426
|
+
|
|
427
|
+
def check_read(self, venture: str, agent_id: str, target_path: str) -> bool:
|
|
428
|
+
"""Validate a read path. More permissive than writes -- allows shared libs."""
|
|
429
|
+
resolved = Path(target_path).resolve()
|
|
430
|
+
|
|
431
|
+
# Reads within namespace are always fine
|
|
432
|
+
if self._ns.is_within_namespace(venture, str(resolved)):
|
|
433
|
+
return True
|
|
434
|
+
|
|
435
|
+
# Reads from shared libs are fine (handled by SharedLibs)
|
|
436
|
+
# Reads from /tmp are fine
|
|
437
|
+
if str(resolved).startswith("/tmp"):
|
|
438
|
+
return True
|
|
439
|
+
|
|
440
|
+
self._logger.warn(
|
|
441
|
+
f"Read outside namespace: {target_path}",
|
|
442
|
+
venture=venture,
|
|
443
|
+
agent_id=agent_id,
|
|
444
|
+
action="jail_read_warning",
|
|
445
|
+
)
|
|
446
|
+
# Reads are advisory warnings, not hard blocks
|
|
447
|
+
return True
|
|
448
|
+
|
|
449
|
+
def _raise_violation(self, venture: str, agent_id: str, path: str, reason: str):
|
|
450
|
+
self._logger.error(
|
|
451
|
+
f"JAIL VIOLATION: {reason} (path={path})",
|
|
452
|
+
venture=venture,
|
|
453
|
+
agent_id=agent_id,
|
|
454
|
+
action="jail_violation",
|
|
455
|
+
extra={"path": path, "reason": reason},
|
|
456
|
+
)
|
|
457
|
+
raise DirectoryJailViolation(
|
|
458
|
+
f"Agent '{agent_id}' (venture: {venture}) blocked: {reason}. Path: {path}"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# =========================================================================
|
|
463
|
+
# SharedLibs — read-only access to common utilities
|
|
464
|
+
# =========================================================================
|
|
465
|
+
|
|
466
|
+
class SharedLibs:
|
|
467
|
+
"""Registry of shared libraries that agents can read but not modify.
|
|
468
|
+
|
|
469
|
+
The governor controls which paths are exposed as shared libs.
|
|
470
|
+
Agents get read-only access; any write attempt through the jail
|
|
471
|
+
will be blocked.
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
SHARED_LIBS_FILE = SWARM_DIR / "shared_libs.json"
|
|
475
|
+
|
|
476
|
+
# Default shared lib paths that all ventures can read
|
|
477
|
+
DEFAULT_LIBS: List[Dict[str, str]] = [
|
|
478
|
+
{
|
|
479
|
+
"name": "delimit_core",
|
|
480
|
+
"path": str(Path.home() / ".delimit" / "server"),
|
|
481
|
+
"description": "Core Delimit MCP server (read-only)",
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
"name": "governance_policies",
|
|
485
|
+
"path": str(Path.home() / ".delimit" / "governance"),
|
|
486
|
+
"description": "Governance policy definitions",
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
"name": "shared_schemas",
|
|
490
|
+
"path": str(Path.home() / ".delimit" / "schemas"),
|
|
491
|
+
"description": "Shared data schemas across ventures",
|
|
492
|
+
},
|
|
493
|
+
]
|
|
494
|
+
|
|
495
|
+
def __init__(self):
|
|
496
|
+
SWARM_DIR.mkdir(parents=True, exist_ok=True)
|
|
497
|
+
|
|
498
|
+
def register_lib(
|
|
499
|
+
self,
|
|
500
|
+
name: str,
|
|
501
|
+
path: str,
|
|
502
|
+
description: str = "",
|
|
503
|
+
ventures: Optional[List[str]] = None,
|
|
504
|
+
) -> Dict[str, Any]:
|
|
505
|
+
"""Register a shared library path. Only the governor should call this."""
|
|
506
|
+
if not name or not path:
|
|
507
|
+
return {"error": "name and path are required"}
|
|
508
|
+
|
|
509
|
+
resolved = str(Path(path).resolve())
|
|
510
|
+
libs = self._load_libs()
|
|
511
|
+
libs[name] = {
|
|
512
|
+
"name": name,
|
|
513
|
+
"path": resolved,
|
|
514
|
+
"description": description,
|
|
515
|
+
"ventures": ventures or ["*"], # "*" means all ventures
|
|
516
|
+
"registered_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
517
|
+
}
|
|
518
|
+
self._save_libs(libs)
|
|
519
|
+
|
|
520
|
+
return {"status": "registered", "name": name, "path": resolved}
|
|
521
|
+
|
|
522
|
+
def unregister_lib(self, name: str) -> Dict[str, Any]:
|
|
523
|
+
"""Remove a shared library from the registry."""
|
|
524
|
+
libs = self._load_libs()
|
|
525
|
+
if name not in libs:
|
|
526
|
+
return {"status": "not_found", "name": name}
|
|
527
|
+
del libs[name]
|
|
528
|
+
self._save_libs(libs)
|
|
529
|
+
return {"status": "removed", "name": name}
|
|
530
|
+
|
|
531
|
+
def list_libs(self, venture: str = "") -> List[Dict[str, str]]:
|
|
532
|
+
"""List available shared libraries, optionally filtered by venture."""
|
|
533
|
+
libs = self._load_libs()
|
|
534
|
+
result = []
|
|
535
|
+
for lib in libs.values():
|
|
536
|
+
allowed = lib.get("ventures", ["*"])
|
|
537
|
+
if venture and "*" not in allowed and venture not in allowed:
|
|
538
|
+
continue
|
|
539
|
+
result.append({
|
|
540
|
+
"name": lib["name"],
|
|
541
|
+
"path": lib["path"],
|
|
542
|
+
"description": lib.get("description", ""),
|
|
543
|
+
})
|
|
544
|
+
return result
|
|
545
|
+
|
|
546
|
+
def can_access(self, venture: str, path: str) -> bool:
|
|
547
|
+
"""Check if a venture can access a path via shared libs."""
|
|
548
|
+
resolved = str(Path(path).resolve())
|
|
549
|
+
libs = self._load_libs()
|
|
550
|
+
for lib in libs.values():
|
|
551
|
+
allowed = lib.get("ventures", ["*"])
|
|
552
|
+
if "*" not in allowed and venture not in allowed:
|
|
553
|
+
continue
|
|
554
|
+
if resolved.startswith(lib["path"]):
|
|
555
|
+
return True
|
|
556
|
+
return False
|
|
557
|
+
|
|
558
|
+
def get_lib_paths(self, venture: str = "") -> Set[str]:
|
|
559
|
+
"""Get all shared lib root paths accessible to a venture."""
|
|
560
|
+
paths: Set[str] = set()
|
|
561
|
+
for lib in self.list_libs(venture=venture):
|
|
562
|
+
paths.add(lib["path"])
|
|
563
|
+
return paths
|
|
564
|
+
|
|
565
|
+
def _load_libs(self) -> Dict[str, Any]:
|
|
566
|
+
if not self.SHARED_LIBS_FILE.exists():
|
|
567
|
+
# Bootstrap with defaults
|
|
568
|
+
libs = {}
|
|
569
|
+
for d in self.DEFAULT_LIBS:
|
|
570
|
+
libs[d["name"]] = {**d, "ventures": ["*"], "registered_at": "bootstrap"}
|
|
571
|
+
return libs
|
|
572
|
+
try:
|
|
573
|
+
return json.loads(self.SHARED_LIBS_FILE.read_text())
|
|
574
|
+
except (json.JSONDecodeError, OSError):
|
|
575
|
+
return {}
|
|
576
|
+
|
|
577
|
+
def _save_libs(self, libs: Dict[str, Any]):
|
|
578
|
+
self.SHARED_LIBS_FILE.write_text(json.dumps(libs, indent=2))
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# =========================================================================
|
|
582
|
+
# Convenience: single entry point for swarm infra validation
|
|
583
|
+
# =========================================================================
|
|
584
|
+
|
|
585
|
+
def validate_agent_operation(
|
|
586
|
+
venture: str,
|
|
587
|
+
agent_id: str,
|
|
588
|
+
token: str,
|
|
589
|
+
target_path: str,
|
|
590
|
+
operation: str = "write",
|
|
591
|
+
) -> Dict[str, Any]:
|
|
592
|
+
"""Full validation pipeline: auth -> namespace -> jail -> shared libs.
|
|
593
|
+
|
|
594
|
+
Returns a structured result with pass/fail and details.
|
|
595
|
+
Raises DirectoryJailViolation on write violations.
|
|
596
|
+
"""
|
|
597
|
+
auth = SwarmAuth()
|
|
598
|
+
jail = DirectoryJail()
|
|
599
|
+
shared = SharedLibs()
|
|
600
|
+
logger = SwarmLogger()
|
|
601
|
+
|
|
602
|
+
# 1. Authenticate
|
|
603
|
+
auth_result = auth.validate_token(token)
|
|
604
|
+
if not auth_result.get("valid"):
|
|
605
|
+
logger.error(
|
|
606
|
+
f"Auth failed: {auth_result.get('error')}",
|
|
607
|
+
venture=venture,
|
|
608
|
+
agent_id=agent_id,
|
|
609
|
+
action="validate_operation",
|
|
610
|
+
)
|
|
611
|
+
return {"allowed": False, "stage": "auth", "error": auth_result.get("error")}
|
|
612
|
+
|
|
613
|
+
# 2. Verify token matches claimed identity
|
|
614
|
+
if auth_result["venture"] != venture or auth_result["agent_id"] != agent_id:
|
|
615
|
+
logger.error(
|
|
616
|
+
f"Identity mismatch: token is for {auth_result['venture']}/{auth_result['agent_id']}",
|
|
617
|
+
venture=venture,
|
|
618
|
+
agent_id=agent_id,
|
|
619
|
+
action="validate_operation",
|
|
620
|
+
)
|
|
621
|
+
return {"allowed": False, "stage": "identity", "error": "Token does not match claimed identity"}
|
|
622
|
+
|
|
623
|
+
# 3. Check operation
|
|
624
|
+
if operation == "write":
|
|
625
|
+
# This raises DirectoryJailViolation on failure
|
|
626
|
+
jail.check_write(venture, agent_id, target_path)
|
|
627
|
+
elif operation == "read":
|
|
628
|
+
# Check shared libs for cross-namespace reads
|
|
629
|
+
ns = VentureNamespace()
|
|
630
|
+
if not ns.is_within_namespace(venture, target_path):
|
|
631
|
+
if not shared.can_access(venture, target_path):
|
|
632
|
+
logger.warn(
|
|
633
|
+
f"Read denied: {target_path} not in namespace or shared libs",
|
|
634
|
+
venture=venture,
|
|
635
|
+
agent_id=agent_id,
|
|
636
|
+
action="validate_operation",
|
|
637
|
+
)
|
|
638
|
+
return {
|
|
639
|
+
"allowed": False,
|
|
640
|
+
"stage": "namespace",
|
|
641
|
+
"error": "Path not in namespace or shared libs",
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
logger.info(
|
|
645
|
+
f"Operation '{operation}' approved for {target_path}",
|
|
646
|
+
venture=venture,
|
|
647
|
+
agent_id=agent_id,
|
|
648
|
+
action="validate_operation",
|
|
649
|
+
)
|
|
650
|
+
return {
|
|
651
|
+
"allowed": True,
|
|
652
|
+
"venture": venture,
|
|
653
|
+
"agent_id": agent_id,
|
|
654
|
+
"operation": operation,
|
|
655
|
+
"target_path": target_path,
|
|
656
|
+
}
|