@catalyst-cloud/cli 0.14.10 → 0.15.0

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +41 -0
  3. package/dist/args.js +34 -2
  4. package/dist/capabilities.js +2 -1
  5. package/dist/cli-update-notice.js +76 -0
  6. package/dist/cli.js +263 -101
  7. package/dist/oauth.js +48 -18
  8. package/dist/onboard-accounts.js +34 -5
  9. package/dist/onboard-automation-management.js +27 -9
  10. package/dist/onboard-capacity.js +10 -1
  11. package/dist/onboard-existing.js +132 -31
  12. package/dist/onboard-github.js +171 -6
  13. package/dist/onboard-headless.js +647 -0
  14. package/dist/onboard-interrupts.js +41 -0
  15. package/dist/onboard-next.js +215 -0
  16. package/dist/onboard-permissions.js +100 -0
  17. package/dist/onboard-personal.js +46 -10
  18. package/dist/onboard-runner-cloud.js +213 -0
  19. package/dist/onboard-runner-egress.js +67 -0
  20. package/dist/onboard-runner.js +780 -0
  21. package/dist/onboard-runtime.js +48 -8
  22. package/dist/onboard-standalone-copy.js +110 -0
  23. package/dist/onboard-team-create.js +267 -0
  24. package/dist/onboard-ui.js +623 -29
  25. package/dist/onboard-workflow.js +311 -168
  26. package/dist/onboard-workspace.js +52 -5
  27. package/dist/onboard.js +359 -212
  28. package/dist/setup-events.js +100 -0
  29. package/dist/setup-onboard-copy.js +438 -0
  30. package/dist/setup-prompt-frame.js +94 -0
  31. package/dist/setup-prompts.js +108 -0
  32. package/dist/setup-render.js +339 -0
  33. package/dist/setup-signin.js +187 -0
  34. package/dist/setup.js +426 -0
  35. package/package.json +7 -3
  36. package/skills/catalyst-github/SKILL.md +1 -1
  37. package/skills/catalyst-linear/SKILL.md +1 -1
  38. package/skills/catalyst-onboard/SKILL.md +3 -2
  39. package/skills/run-this-project/SKILL.md +1 -1
  40. package/skills/unstick/SKILL.md +1 -1
  41. package/skills/what-needs-me/SKILL.md +1 -1
  42. package/skills/what-this-repo-needs/SKILL.md +1 -1
  43. package/skills/whats-happening/SKILL.md +1 -1
  44. package/vendor/README.md +4 -0
  45. package/vendor/self-host/compose.yaml +110 -0
  46. package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.service +14 -0
  47. package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.timer +10 -0
  48. package/vendor/self-host/linux-session-egress/catalyst-session-egress.service +19 -0
  49. package/vendor/self-host/linux-session-egress/session-egress.py +291 -0
  50. package/vendor/self-host/provenance.json +11 -0
@@ -0,0 +1,110 @@
1
+ # deploy/self-host/compose.yaml — one Catalyst host on one Docker engine.
2
+ #
3
+ # The same file runs on Linux (amd64 or arm64) and on macOS through OrbStack or Docker Desktop. It
4
+ # assumes nothing from the host's init system: no systemd unit, timer or drop-in. Copy .env.example
5
+ # to .env, fill it in, then `docker compose up -d`. README.md explains every choice below.
6
+ #
7
+ # Two long-running services, and neither depends on the other:
8
+ # - supervisor: enrolls this host with a one-time join token, leases work from the tenant DO, and
9
+ # runs each phase as disposable setup and session containers (apps/host-agent/Dockerfile.supervisor).
10
+ # - deadline-watchdog: stops an attempt container once its hard deadline passes, including while
11
+ # the supervisor is dead. That is why it has no `depends_on`.
12
+ # Attempt containers are NOT services here. The supervisor creates them through the Docker API.
13
+
14
+ name: catalyst-host
15
+
16
+ x-hardened: &hardened
17
+ read_only: true
18
+ cap_drop: [ALL]
19
+ security_opt: ["no-new-privileges:true"]
20
+ # ⛔ unless-stopped, never on-failure. A daemon can exit 0 when every slot has retired (today's
21
+ # host agent does exactly that), and on-failure leaves an exit-0 service down for good.
22
+ restart: unless-stopped
23
+ logging:
24
+ driver: local
25
+ options:
26
+ max-size: 10m
27
+ max-file: "3"
28
+ tmpfs:
29
+ - /tmp:size=16m,mode=1777
30
+
31
+ services:
32
+ deadline-watchdog:
33
+ <<: *hardened
34
+ image: ${CATALYST_WATCHDOG_IMAGE:?set CATALYST_WATCHDOG_IMAGE to a digest-pinned image, see .env.example}
35
+ # The Docker socket is root:<gid> 0660 on every engine we run on (docker group on Linux, gid 0 on
36
+ # OrbStack and Docker Desktop). The image runs as uid 10003, so it needs that group and no more.
37
+ group_add:
38
+ - "${DOCKER_SOCKET_GID:?set DOCKER_SOCKET_GID, see .env.example}"
39
+ volumes:
40
+ - ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
41
+ environment:
42
+ CATALYST_WATCHDOG_STOP_GRACE_SECONDS: ${CATALYST_WATCHDOG_STOP_GRACE_SECONDS:-120}
43
+ CATALYST_WATCHDOG_INTERVAL_MS: ${CATALYST_WATCHDOG_INTERVAL_MS:-5000}
44
+ CATALYST_WATCHDOG_MAX_UNREACHABLE_MS: ${CATALYST_WATCHDOG_MAX_UNREACHABLE_MS:-30000}
45
+ CATALYST_WATCHDOG_DOCKER_API_VERSION: ${CATALYST_WATCHDOG_DOCKER_API_VERSION:-1.44}
46
+ # A stop it has started keeps running on the engine's side after the watchdog exits.
47
+ stop_grace_period: 10s
48
+
49
+ supervisor:
50
+ <<: *hardened
51
+ # The environment names are the contract's SELF_HOST_SUPERVISOR_ENV
52
+ # (packages/protocol/src/host-enrollment.ts), exactly; a test in apps/deadline-watchdog holds
53
+ # this file to that list. The image runs as 10001:10001 with a read-only root; exit 1 means the
54
+ # Engine was unreachable for 2 min (a fresh container remounts the socket), exit 2 a config error.
55
+ image: ${CATALYST_SUPERVISOR_IMAGE:?set CATALYST_SUPERVISOR_IMAGE to a digest-pinned image, see .env.example}
56
+ group_add:
57
+ - "${DOCKER_SOCKET_GID:?set DOCKER_SOCKET_GID, see .env.example}"
58
+ volumes:
59
+ - ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
60
+ # The host journal and outbox. Never authoritative: deleting it costs undelivered evidence and
61
+ # never blocks a start (docs/design/disposable-host-contracts.md, "The host journal").
62
+ - supervisor-state:/var/lib/catalyst/supervisor
63
+ # The per-host credential and the tenant's runner org key (CATALYST_ORG_KEY_FILE), on their own
64
+ # volume so the delete-the-state-directory test (removing supervisor-state) never costs an
65
+ # enrollment. The supervisor replaces the credential by rename, so the directory stays writable.
66
+ - host-credential:/var/lib/catalyst/host-credential
67
+ # ⛔ SAME PATH ON BOTH SIDES. The supervisor passes workspace paths to `docker create`, and the
68
+ # engine resolves a bind source on the HOST. A path that exists only inside this container
69
+ # mounts an empty directory into the phase.
70
+ - ${CATALYST_SLOTS_DIR:-/srv/catalyst/slots}:${CATALYST_SLOTS_DIR:-/srv/catalyst/slots}
71
+ # Thoughts stay as today (CTC-3493): a tenant's thoughts checkout and its pool lock directory
72
+ # are SHARED and read-write across that tenant's session containers, and every thoughts sync
73
+ # runs under thoughts.lock. The supervisor keeps one subdirectory per tenant under each root,
74
+ # so two projects on one engine never share a checkout or a lock. Same path on both sides for
75
+ # the same reason as the slots: the supervisor binds these into session containers, and the
76
+ # engine resolves bind sources on the host. Never mounted into a setup container.
77
+ - ${CATALYST_THOUGHTS_DIR:-/srv/catalyst/thoughts}:${CATALYST_THOUGHTS_DIR:-/srv/catalyst/thoughts}
78
+ - ${CATALYST_POOL_LOCK_DIR:-/srv/catalyst/locks}:${CATALYST_POOL_LOCK_DIR:-/srv/catalyst/locks}
79
+ # CTC-4266: the host's proof that its nftables chain drops private ranges from the session
80
+ # bridge, written by a root timer. Read-only. Only a native Linux engine needs a file in it;
81
+ # on Docker Desktop the directory stays empty (README.md, "Create the session network").
82
+ - ${CATALYST_SESSION_EGRESS_DIR:-./session-egress}:/var/lib/catalyst/session-egress:ro
83
+ environment:
84
+ CATALYST_MIRROR_URL: ${CATALYST_MIRROR_URL:-https://staging.catalystcloud.dev}
85
+ CATALYST_HOST_NAME: ${CATALYST_HOST_NAME:-}
86
+ CATALYST_HOST_JOIN_TOKEN: ${CATALYST_HOST_JOIN_TOKEN:-}
87
+ CATALYST_RUNNER_IMAGE: ${CATALYST_RUNNER_IMAGE:?set CATALYST_RUNNER_IMAGE to name@sha256, see .env.example}
88
+ CATALYST_SLOTS: ${CATALYST_SLOTS:-2}
89
+ CATALYST_SLOTS_DIR: ${CATALYST_SLOTS_DIR:-/srv/catalyst/slots}
90
+ # Empty lets the supervisor choose: filesystem when it finds per-slot filesystems on a native
91
+ # Linux engine, budget otherwise (always budget on macOS).
92
+ CATALYST_SLOT_DISK_MODE: ${CATALYST_SLOT_DISK_MODE:-}
93
+ CATALYST_SLOT_DISK_BUDGET_GIB: ${CATALYST_SLOT_DISK_BUDGET_GIB:-20}
94
+ CATALYST_HOST_CREDENTIAL_FILE: ${CATALYST_HOST_CREDENTIAL_FILE:-/var/lib/catalyst/host-credential/credential}
95
+ CATALYST_DISK_BUDGET_GIB: ${CATALYST_DISK_BUDGET_GIB:-60}
96
+ CATALYST_WORKSPACE_STORAGE: ${CATALYST_WORKSPACE_STORAGE:-}
97
+ CATALYST_THOUGHTS_DIR: ${CATALYST_THOUGHTS_DIR:-/srv/catalyst/thoughts}
98
+ CATALYST_POOL_LOCK_DIR: ${CATALYST_POOL_LOCK_DIR:-/srv/catalyst/locks}
99
+ CATALYST_ORG_KEY_FILE: ${CATALYST_ORG_KEY_FILE:-/var/lib/catalyst/host-credential/org-key}
100
+ # Every setup and session container joins this network. It must exist before the supervisor
101
+ # takes work: missing or not isolated is not-ready, never the default bridge.
102
+ CATALYST_SESSION_NETWORK: ${CATALYST_SESSION_NETWORK:-catalyst-session-v1}
103
+ CATALYST_SESSION_EGRESS_ATTESTATION: ${CATALYST_SESSION_EGRESS_ATTESTATION:-/var/lib/catalyst/session-egress/attestation.json}
104
+ # SIGTERM re-adopts on the next start instead of stopping phases, so the supervisor needs only
105
+ # long enough to flush its journal.
106
+ stop_grace_period: 30s
107
+
108
+ volumes:
109
+ supervisor-state:
110
+ host-credential:
@@ -0,0 +1,14 @@
1
+ [Unit]
2
+ Description=Prove the Catalyst session egress chain and rewrite its attestation (CTC-4415)
3
+ After=catalyst-session-egress.service
4
+ Requires=docker.service
5
+
6
+ [Service]
7
+ Type=oneshot
8
+ ExecStart=/usr/bin/python3 /usr/lib/catalyst/session-egress.py attest
9
+ NoNewPrivileges=yes
10
+ ProtectSystem=strict
11
+ StateDirectory=catalyst/session-egress
12
+ StateDirectoryMode=0755
13
+ ProtectHome=yes
14
+ PrivateTmp=yes
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=Re-prove the Catalyst session egress chain every 5 minutes (the supervisor accepts 15)
3
+
4
+ [Timer]
5
+ OnBootSec=2min
6
+ OnUnitActiveSec=5min
7
+ Unit=catalyst-session-egress-attest.service
8
+
9
+ [Install]
10
+ WantedBy=timers.target
@@ -0,0 +1,19 @@
1
+ [Unit]
2
+ Description=Catalyst self-host session network and its egress chain (CTC-4415)
3
+ After=docker.service network-online.target
4
+ Requires=docker.service
5
+ Wants=network-online.target
6
+
7
+ [Service]
8
+ Type=oneshot
9
+ ExecStart=/usr/bin/python3 /usr/lib/catalyst/session-egress.py converge
10
+ RemainAfterExit=yes
11
+ NoNewPrivileges=yes
12
+ ProtectSystem=strict
13
+ StateDirectory=catalyst/session-egress
14
+ StateDirectoryMode=0755
15
+ ProtectHome=yes
16
+ PrivateTmp=yes
17
+
18
+ [Install]
19
+ WantedBy=multi-user.target
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env python3
2
+ """Own and prove the self-host session network's egress policy (CTC-4415, for CTC-4266).
3
+
4
+ A self-hosted supervisor on a native Linux engine is not-ready until it reads a fresh attestation
5
+ that an nftables forward chain drops private ranges from its session bridge (`host.session-egress`,
6
+ apps/host-agent/src/supervisor/network.ts). The supervisor runs unprivileged and cannot read
7
+ nftables, so this root helper does the work and writes the proof:
8
+
9
+ converge make sure catalyst-session-v1 exists with the reviewed shape, install the forward and
10
+ input chains in its own table, then attest (the boot service).
11
+ attest prove both chains and the network still match, and rewrite the attestation (the 5-minute
12
+ timer). Any mismatch removes the old attestation, so the host goes not-ready at once.
13
+
14
+ Modeled on apps/host-provision/infra/ensure-research-network.py and egress-policy.py
15
+ (research-egress-v1), with the same drop list. It owns ONE nftables table, inet catalyst_session,
16
+ and never touches another table, so it is safe on a box with its own firewall.
17
+ """
18
+ import json, os, subprocess, sys, tempfile, time
19
+
20
+ NAME = "catalyst-session-v1"
21
+ # Linux interface names are limited to 15 visible bytes.
22
+ BRIDGE = "catalyst-sess0"
23
+ LABEL_KEY = "dev.catalystcloud.session-network"
24
+ LABEL_VALUE = "v1"
25
+ # Inter-container traffic is off: a phase cannot reach another tenant's container on the bridge.
26
+ OPTIONS = {"com.docker.network.bridge.name": BRIDGE, "com.docker.network.bridge.enable_icc": "false"}
27
+ ENFORCEMENT = "nftables-docker-forward-v1"
28
+ TABLE = "catalyst_session"
29
+ CHAIN = "session_egress"
30
+ # CTC-4415 P1 (Codex, #8870): a session's traffic to the bridge gateway, or to any address the host
31
+ # holds, is delivered locally and traverses INPUT, never FORWARD. A dedicated host drops it with its
32
+ # own default-drop input chain (host-provision's render.ts). An arbitrary self-host box may accept
33
+ # everything there, so this table owns an input chain too.
34
+ INPUT_CHAIN = "session_input"
35
+ DEFAULT_ATTESTATION = "/var/lib/catalyst/session-egress/attestation.json"
36
+ # The gateway, RFC 1918, CGNAT, loopback, link-local, benchmarking, multicast and reserved ranges:
37
+ # the research-egress-v1 list, kept equal by apps/host-agent/test/session-egress-producer.test.ts.
38
+ IPV4 = [('0.0.0.0', 8), ('10.0.0.0', 8), ('100.64.0.0', 10), ('127.0.0.0', 8), ('169.254.0.0', 16), ('172.16.0.0', 12), ('192.0.0.0', 24), ('192.168.0.0', 16), ('198.18.0.0', 15), ('224.0.0.0', 4), ('240.0.0.0', 4)]
39
+ IPV6 = [('::1', 128), ('fc00::', 7), ('fe80::', 10), ('ff00::', 8)]
40
+
41
+
42
+ class Refusal(RuntimeError):
43
+ pass
44
+
45
+
46
+ def run(argv, stdin=None):
47
+ try:
48
+ return subprocess.run(
49
+ argv,
50
+ input=stdin,
51
+ capture_output=True,
52
+ text=True,
53
+ timeout=30,
54
+ env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/var/empty", "LC_ALL": "C"},
55
+ )
56
+ except (OSError, subprocess.TimeoutExpired) as error:
57
+ raise Refusal(os.path.basename(argv[0]) + "_unavailable") from error
58
+
59
+
60
+ # ---------------------------------------------------------------------------------------------
61
+ # The network.
62
+
63
+ def inspect(docker):
64
+ result = run([docker, "network", "inspect", NAME])
65
+ if result.returncode != 0:
66
+ # Docker 29 prints [] for a missing network; Docker 28 prints nothing.
67
+ missing = f"Error response from daemon: network {NAME} not found"
68
+ if result.stdout.strip() not in ("", "[]") or result.stderr.strip() != missing:
69
+ raise Refusal("network_inspection_failed")
70
+ return None
71
+ try:
72
+ value = json.loads(result.stdout)
73
+ except json.JSONDecodeError as error:
74
+ raise Refusal("network_inspection_invalid") from error
75
+ if not isinstance(value, list) or len(value) != 1 or not isinstance(value[0], dict):
76
+ raise Refusal("network_inspection_invalid")
77
+ return value[0]
78
+
79
+
80
+ def matches(network):
81
+ """The keys the supervisor verifies (sessionNetworkShape), plus this helper's own bridge name,
82
+ which its chains are bound to. Required keys only: an operator's extra option (an MTU) or an
83
+ unrelated label is kept, as the supervisor keeps it (CTC-4415 P2, Codex #8870), never removed
84
+ with the network. No subnet is pinned: Docker picks a free one, and a customer box may already
85
+ use research-egress-v1's 172.30.0.0/24."""
86
+ if not isinstance(network, dict):
87
+ return False
88
+ options = network.get("Options")
89
+ labels = network.get("Labels")
90
+ return (
91
+ network.get("Name") == NAME
92
+ and network.get("Driver") == "bridge"
93
+ and network.get("Internal") is False
94
+ and network.get("EnableIPv6") is False
95
+ and network.get("Ingress") is False
96
+ and network.get("ConfigOnly", False) is False
97
+ and isinstance(options, dict)
98
+ and all(options.get(key) == value for key, value in OPTIONS.items())
99
+ and isinstance(labels, dict)
100
+ and labels.get(LABEL_KEY) == LABEL_VALUE
101
+ and isinstance(network.get("Containers"), dict)
102
+ )
103
+
104
+
105
+ def create(docker):
106
+ result = run(
107
+ [
108
+ docker,
109
+ "network",
110
+ "create",
111
+ "--driver=bridge",
112
+ *["--opt=" + key + "=" + value for key, value in OPTIONS.items()],
113
+ "--label=" + LABEL_KEY + "=" + LABEL_VALUE,
114
+ NAME,
115
+ ]
116
+ )
117
+ if result.returncode != 0 or result.stdout.strip() == "":
118
+ raise Refusal("network_create_failed")
119
+ if not matches(inspect(docker)):
120
+ raise Refusal("network_create_mismatch")
121
+
122
+
123
+ def ensure_network(docker):
124
+ network = inspect(docker)
125
+ if network is None:
126
+ create(docker)
127
+ return "created"
128
+ if matches(network):
129
+ return "unchanged"
130
+ containers = network.get("Containers")
131
+ if not isinstance(containers, dict):
132
+ raise Refusal("network_inspection_invalid")
133
+ # ⛔ Never under a running phase: removing it would cut that phase off mid-session.
134
+ if containers:
135
+ raise Refusal("network_attached")
136
+ if run([docker, "network", "rm", NAME]).returncode != 0:
137
+ raise Refusal("network_remove_failed")
138
+ create(docker)
139
+ return "replaced"
140
+
141
+
142
+ # ---------------------------------------------------------------------------------------------
143
+ # The chain.
144
+
145
+ def ruleset():
146
+ """One atomic transaction: declare the table (a no-op when present), delete it, and add it back
147
+ whole, so a re-run replaces both chains exactly and never duplicates a rule."""
148
+ rules = [f' iifname "{BRIDGE}" ip daddr {addr}/{length} counter drop' for addr, length in IPV4]
149
+ rules += [f' iifname "{BRIDGE}" ip6 daddr {addr}/{length} counter drop' for addr, length in IPV6]
150
+ return (
151
+ f"table inet {TABLE} {{}}\n"
152
+ f"delete table inet {TABLE}\n"
153
+ f"table inet {TABLE} {{\n"
154
+ f" chain {CHAIN} {{\n"
155
+ # Ahead of Docker's own forward rules (priority 0). A drop in any base chain is final.
156
+ " type filter hook forward priority -10; policy accept;\n"
157
+ + "\n".join(rules)
158
+ + "\n }\n"
159
+ f" chain {INPUT_CHAIN} {{\n"
160
+ # Nothing on the host is a session's to reach: only replies to connections the host itself
161
+ # opened pass. Public egress is forwarded, and a container's DNS resolver (127.0.0.11) lives
162
+ # in the container's own namespace, so neither needs INPUT.
163
+ " type filter hook input priority -10; policy accept;\n"
164
+ f' iifname "{BRIDGE}" ct state established,related accept\n'
165
+ f' iifname "{BRIDGE}" counter drop\n'
166
+ " }\n}\n"
167
+ )
168
+
169
+
170
+ def apply_chain(nft):
171
+ if run([nft, "-f", "-"], stdin=ruleset()).returncode != 0:
172
+ raise Refusal("chain_apply_failed")
173
+
174
+
175
+ def chain_matches(nft):
176
+ """Both chains, exactly as `ruleset()` installs them, read back from nft itself. A missing or
177
+ drifted forward OR input chain is a mismatch, so `attest` withdraws the attestation."""
178
+ result = run([nft, "-j", "list", "table", "inet", TABLE])
179
+ if result.returncode != 0:
180
+ return False
181
+ try:
182
+ entries = json.loads(result.stdout)["nftables"]
183
+ chains = {e["chain"]["name"]: e["chain"] for e in entries if "chain" in e}
184
+ rules = {}
185
+ for e in entries:
186
+ if "rule" in e:
187
+ rules.setdefault(e["rule"].get("chain"), []).append(e["rule"])
188
+ except (KeyError, TypeError, AttributeError, json.JSONDecodeError):
189
+ return False
190
+ if set(chains) != {CHAIN, INPUT_CHAIN}:
191
+ return False
192
+ for name, hook in ((CHAIN, "forward"), (INPUT_CHAIN, "input")):
193
+ want = {"family": "inet", "table": TABLE, "type": "filter", "hook": hook, "prio": -10, "policy": "accept"}
194
+ if any(chains[name].get(k) != v for k, v in want.items()):
195
+ return False
196
+
197
+ def match(protocol, right):
198
+ return {"match": {"op": "==", "left": {"payload": {"protocol": protocol, "field": "daddr"}}, "right": right}}
199
+
200
+ interface = {"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": BRIDGE}}
201
+
202
+ # nft JSON serializes a full-width CIDR as a scalar, unlike shorter prefixes.
203
+ def prefix(address, length):
204
+ return address if length in (32, 128) else {"prefix": {"addr": address, "len": length}}
205
+
206
+ forward = [[interface, match("ip", prefix(a, n)), {"counter": None}, {"drop": None}] for a, n in IPV4]
207
+ forward += [[interface, match("ip6", prefix(a, n)), {"counter": None}, {"drop": None}] for a, n in IPV6]
208
+ established = {"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}
209
+ local = [[interface, established, {"accept": None}], [interface, {"counter": None}, {"drop": None}]]
210
+
211
+ def canonical(expr):
212
+ return [{"counter": None} if isinstance(item, dict) and set(item) == {"counter"} else item for item in expr]
213
+
214
+ def exactly(found, expected):
215
+ return len(found) == len(expected) and all(
216
+ canonical(rule.get("expr", [])) == want
217
+ and set(rule).issubset({"family", "table", "chain", "handle", "expr", "comment"})
218
+ for rule, want in zip(found, expected)
219
+ )
220
+
221
+ return exactly(rules.get(CHAIN, []), forward) and exactly(rules.get(INPUT_CHAIN, []), local)
222
+
223
+
224
+ # ---------------------------------------------------------------------------------------------
225
+ # The attestation (read by the supervisor's checkEgressAttestation).
226
+
227
+ def withdraw(path):
228
+ try:
229
+ os.unlink(path)
230
+ except FileNotFoundError:
231
+ pass
232
+
233
+
234
+ def attest(docker, nft, path):
235
+ try:
236
+ if not chain_matches(nft):
237
+ raise Refusal("chain_mismatch")
238
+ if not matches(inspect(docker)):
239
+ raise Refusal("network_mismatch")
240
+ except Refusal:
241
+ # A stale proof would keep the host placeable for up to 15 minutes after the policy is gone.
242
+ withdraw(path)
243
+ raise
244
+ directory = os.path.dirname(path)
245
+ os.makedirs(directory, mode=0o755, exist_ok=True)
246
+ body = json.dumps(
247
+ {"version": 1, "enforcement": ENFORCEMENT, "bridge": BRIDGE, "verifiedAtMs": int(time.time() * 1000)}
248
+ )
249
+ handle, temporary = tempfile.mkstemp(prefix=".attestation.", dir=directory)
250
+ try:
251
+ with os.fdopen(handle, "w") as out:
252
+ out.write(body + "\n")
253
+ # The supervisor (uid 10001) reads it through a read-only mount.
254
+ os.chmod(temporary, 0o644)
255
+ os.replace(temporary, path)
256
+ except BaseException:
257
+ withdraw(temporary)
258
+ raise
259
+ return "attested"
260
+
261
+
262
+ def converge(docker, nft, path):
263
+ try:
264
+ outcome = ensure_network(docker)
265
+ apply_chain(nft)
266
+ except Refusal:
267
+ withdraw(path)
268
+ raise
269
+ attest(docker, nft, path)
270
+ return outcome
271
+
272
+
273
+ def main(argv):
274
+ if not argv or argv[0] not in ("converge", "attest"):
275
+ raise Refusal("invalid_arguments")
276
+ options = {"docker": "/usr/bin/docker", "nft": "/usr/sbin/nft", "attestation": DEFAULT_ATTESTATION}
277
+ for arg in argv[1:]:
278
+ key, _, value = arg.partition("=")
279
+ if not key.startswith("--") or key[2:] not in options or value == "":
280
+ raise Refusal("invalid_arguments")
281
+ options[key[2:]] = value
282
+ action = converge if argv[0] == "converge" else attest
283
+ return action(options["docker"], options["nft"], options["attestation"])
284
+
285
+
286
+ if __name__ == "__main__":
287
+ try:
288
+ print(main(sys.argv[1:]))
289
+ except Refusal as error:
290
+ print("catalyst_session_egress_" + str(error), file=sys.stderr)
291
+ raise SystemExit(1)
@@ -0,0 +1,11 @@
1
+ {
2
+ "repository": "coalesce-labs/catalyst-cloud",
3
+ "commit": "3efb201e119fb9ce5a3f636c3212fcbed2d7efef",
4
+ "sha256": {
5
+ "deploy/self-host/compose.yaml": "302f4cd6740572bf1a40386f8ec0d3a1b6919fe8c5e303f232c36969d8f36a53",
6
+ "deploy/self-host/linux-session-egress/session-egress.py": "a20cedfa1e883127ec461e4b7f9588d379fe1633b8244ecd703c81f589d3f142",
7
+ "deploy/self-host/linux-session-egress/catalyst-session-egress.service": "703c150ab23ec87aea47cfc394f63e061b66c4b8c1454a88a9be879c161ce2a5",
8
+ "deploy/self-host/linux-session-egress/catalyst-session-egress-attest.service": "94fa75ea0616f6a1172fc20ca637a5727c36456357e4a977d5bdd65f2fad2e8d",
9
+ "deploy/self-host/linux-session-egress/catalyst-session-egress-attest.timer": "176622c11c835601b95f36e375ba3c2e993136976c67e5d49559f13a3454fef9"
10
+ }
11
+ }