clawdi 0.15.5 → 0.15.7
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/dist/index.js +655 -572
- package/egress-addon/clawdi_egress_addon.py +117 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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