clawdi 0.15.4 → 0.15.6

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.
@@ -7,9 +7,13 @@ secret JSON file generated by the CLI at runtime.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import asyncio
11
+ import errno
12
+ import hashlib
10
13
  import json
11
14
  import os
12
15
  import re
16
+ import stat
13
17
  from datetime import UTC, datetime
14
18
  from pathlib import Path
15
19
  from typing import Any
@@ -74,10 +78,30 @@ class ClawdiEgressAddon:
74
78
  self.profiles: list[dict[str, Any]] = []
75
79
  self.secrets: dict[str, str] = {}
76
80
  self.profile_hosts: set[str] = set()
81
+ self.snapshot_path: Path | None = None
82
+ self.snapshot_ack: Path | None = None
83
+ self.snapshot_digest: str | None = None
84
+ self.snapshot_identity: tuple[int, ...] | None = None
85
+ self.snapshot_claimed = True
86
+ self.snapshot_task: asyncio.Task | None = None
77
87
 
78
88
  def load(self, loader: Any) -> None: # pragma: no cover - mitmproxy integration glue.
79
89
  self.reload_from_environment(os.environ)
80
90
 
91
+ def running(self) -> None: # pragma: no cover - native integration glue.
92
+ # ScriptLoader invokes RunningHook synchronously, including reloads.
93
+ if self.snapshot_path:
94
+ self.snapshot_task = asyncio.create_task(self.watch_snapshot())
95
+
96
+ async def watch_snapshot(self) -> None:
97
+ while True:
98
+ self.refresh_snapshot()
99
+ await asyncio.sleep(1)
100
+
101
+ def done(self) -> None: # pragma: no cover - native integration glue.
102
+ if self.snapshot_task:
103
+ self.snapshot_task.cancel()
104
+
81
105
  def requestheaders(self, flow: Any) -> None:
82
106
  decision = self.apply_to_flow(flow)
83
107
  mark_flow_decision_applied(flow)
@@ -107,6 +131,16 @@ class ClawdiEgressAddon:
107
131
 
108
132
  def reload_from_environment(self, environ: dict[str, str | None]) -> None:
109
133
  config = merged_config(environ)
134
+ snapshot = first_nonempty(config.get("CLAWDI_EGRESS_SNAPSHOT_FILE"))
135
+ if snapshot:
136
+ ack = first_nonempty(config.get("CLAWDI_EGRESS_SNAPSHOT_ACK"))
137
+ if not ack:
138
+ raise ConfigError("egress snapshot acknowledgement path is required")
139
+ self.snapshot_claimed = False
140
+ self.snapshot_path = Path(snapshot)
141
+ self.snapshot_ack = Path(ack)
142
+ self.refresh_snapshot()
143
+ return
110
144
  bundle = first_nonempty(config.get("CLAWDI_EGRESS_PROFILE_BUNDLE"))
111
145
  if not bundle:
112
146
  raise ConfigError("CLAWDI_EGRESS_PROFILE_BUNDLE is required")
@@ -119,9 +153,17 @@ class ClawdiEgressAddon:
119
153
  self.profile_hosts = profile_host_set(self.profiles)
120
154
 
121
155
  def should_intercept_sni(self, server_name: str) -> bool:
156
+ if not self.snapshot_claimed:
157
+ return True
122
158
  return normalize_host(server_name) in self.profile_hosts
123
159
 
124
160
  def apply_to_flow(self, flow: Any) -> ProfileDecision:
161
+ if not self.snapshot_claimed:
162
+ flow.response = http.Response.make(
163
+ 503, b"Managed egress is not configured.\n",
164
+ {"content-type": "text/plain; charset=utf-8"},
165
+ )
166
+ return ProfileDecision("deny")
125
167
  profile = first_matching_profile(flow, self.profiles, self.secrets)
126
168
  if profile is None:
127
169
  return ProfileDecision("allow")
@@ -143,6 +185,76 @@ class ClawdiEgressAddon:
143
185
  return ProfileDecision(kind, str(profile.get("id")), profile=profile)
144
186
  return ProfileDecision("allow", str(profile.get("id")), "unknown-kind", profile)
145
187
 
188
+ def refresh_snapshot(self) -> None:
189
+ if not self.snapshot_path:
190
+ return
191
+ identity = None
192
+ try:
193
+ node = self.snapshot_path.lstat()
194
+ identity = (node.st_ino, node.st_mtime_ns, node.st_ctime_ns, node.st_size, node.st_mode)
195
+ if identity == self.snapshot_identity:
196
+ return
197
+ fd = os.open(self.snapshot_path, os.O_RDONLY | os.O_NOFOLLOW)
198
+ with os.fdopen(fd, "rb") as source:
199
+ node = os.fstat(source.fileno())
200
+ if (not stat.S_ISREG(node.st_mode) or node.st_uid != 0
201
+ or node.st_mode & 0o027 or node.st_size > 8 * 1024 * 1024):
202
+ raise ConfigError("egress snapshot authority is invalid")
203
+ content = source.read(8 * 1024 * 1024 + 1)
204
+ if len(content) > 8 * 1024 * 1024:
205
+ raise ConfigError("egress snapshot is too large")
206
+ digest = hashlib.sha256(content).hexdigest()
207
+ raw = json.loads(content)
208
+ if (not isinstance(raw, dict)
209
+ or set(raw) != {"schemaVersion", "claimed", "profiles", "secrets"}
210
+ or raw["schemaVersion"] != "clawdi.egressSnapshot.v1"
211
+ or not isinstance(raw["claimed"], bool)
212
+ or not isinstance(raw["secrets"], dict)
213
+ or not all(isinstance(k, str) and isinstance(v, str)
214
+ for k, v in raw["secrets"].items())):
215
+ raise ConfigError("egress snapshot is invalid")
216
+ profiles = parse_profiles(raw["profiles"])
217
+ secrets = raw["secrets"]
218
+ validate_profile_secrets(profiles, secrets)
219
+ if not raw["claimed"] and (secrets or raw["profiles"] != {
220
+ "schemaVersion": SCHEMA_VERSION, "profiles": []}):
221
+ raise ConfigError("anonymous egress snapshot contains policy or credentials")
222
+ self.profiles, self.secrets = profiles, secrets
223
+ self.profile_hosts = profile_host_set(profiles)
224
+ self.snapshot_claimed = raw["claimed"]
225
+ # Publish only after all policy and credential checks succeed.
226
+ if self.snapshot_ack:
227
+ pending = self.snapshot_ack.with_name(f"ack.{os.getpid()}.tmp")
228
+ fd = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
229
+ with os.fdopen(fd, "w") as target:
230
+ target.write(digest + "\n")
231
+ pending.replace(self.snapshot_ack)
232
+ self.snapshot_digest = digest
233
+ self.snapshot_identity = identity
234
+ except OSError as error:
235
+ if error.errno != errno.ELOOP:
236
+ # Atomic replacement and temporary I/O failure do not revoke a valid policy.
237
+ ctx.log.warn("clawdi egress snapshot read/ack deferred")
238
+ return
239
+ self.invalidate_snapshot(identity)
240
+ except (ConfigError, ValueError, TypeError):
241
+ self.invalidate_snapshot(identity)
242
+
243
+ def invalidate_snapshot(self, identity: tuple[int, ...] | None) -> None:
244
+ ctx.log.error("clawdi egress snapshot unhealthy: invalid replacement")
245
+ # A definitively invalid replacement must never use stale credentials.
246
+ self.snapshot_identity = identity
247
+ self.profiles, self.secrets, self.profile_hosts = [], {}, set()
248
+ self.snapshot_claimed = False
249
+ self.snapshot_digest = None
250
+ if self.snapshot_ack:
251
+ try:
252
+ self.snapshot_ack.unlink(missing_ok=True)
253
+ except OSError:
254
+ pass
255
+ # Engine exit makes the sidecar fail and exposes unhealthy manager state.
256
+ ctx.master.shutdown()
257
+
146
258
 
147
259
  def merged_config(environ: dict[str, str | None]) -> dict[str, str]:
148
260
  config: dict[str, str] = {}
@@ -184,7 +296,11 @@ def load_profiles(path: Path) -> list[dict[str, Any]]:
184
296
  raw = json.loads(path.read_text(encoding="utf-8"))
185
297
  except FileNotFoundError as exc:
186
298
  raise ConfigError(f"egress profile bundle is missing: {path}") from exc
187
- if raw.get("schemaVersion") != SCHEMA_VERSION:
299
+ return parse_profiles(raw)
300
+
301
+
302
+ def parse_profiles(raw: Any) -> list[dict[str, Any]]:
303
+ if not isinstance(raw, dict) or raw.get("schemaVersion") != SCHEMA_VERSION:
188
304
  raise ConfigError(f"egress profile bundle must use {SCHEMA_VERSION}")
189
305
  profiles = raw.get("profiles")
190
306
  if not isinstance(profiles, list):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawdi",
3
- "version": "0.15.4",
3
+ "version": "0.15.6",
4
4
  "description": "The best home for all your AI agents. Run them in the cloud or connect your own—with their context and tools in one place.",
5
5
  "license": "MIT",
6
6
  "type": "module",