swiftapi-python 1.2.2__py3-none-any.whl → 1.3.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.
swiftapi/brake/core.py ADDED
@@ -0,0 +1,420 @@
1
+ """Deterministic authorization. Trusted application code defines conditions and state.
2
+
3
+ Signatures authenticate statements, not the truth of arbitrary state. Execute only
4
+ through a registered broker with credentials outside an untrusted agent's reach.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import secrets
14
+ import sqlite3
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any, Callable, Dict, Optional, Protocol
19
+
20
+ from nacl.signing import SigningKey, VerifyKey
21
+ from nacl.exceptions import BadSignatureError
22
+ from ..exceptions import SwiftAPIError
23
+
24
+
25
+ class BrakeError(SwiftAPIError):
26
+ code = "BRAKE_ERROR"
27
+
28
+
29
+ class InvalidEnvelope(BrakeError):
30
+ code = "INVALID_ENVELOPE"
31
+
32
+
33
+ class AuthorizationDenied(BrakeError):
34
+ code = "GATE_BLOCK"
35
+
36
+
37
+ class ReplayError(AuthorizationDenied):
38
+ code = "REPLAY"
39
+
40
+
41
+ def canonical(value: Any) -> bytes:
42
+ """SwiftAPI JSON v1: UTF-8, sorted keys, no floats, safe integers, no coercion.
43
+
44
+ This is a specified subset of JSON, not a claim of general RFC 8785 support.
45
+ Unicode is preserved exactly (not normalized). Unsupported inputs fail closed.
46
+ """
47
+
48
+ def validate(v: Any, depth: int = 0) -> None:
49
+ if depth > 64:
50
+ raise InvalidEnvelope("JSON nesting exceeds 64")
51
+ if v is None or type(v) in (bool, str):
52
+ return
53
+ if type(v) is int and abs(v) <= 9007199254740991:
54
+ return
55
+ if type(v) is list:
56
+ for child in v:
57
+ validate(child, depth + 1)
58
+ return
59
+ if type(v) is dict and all(type(k) is str for k in v):
60
+ for child in v.values():
61
+ validate(child, depth + 1)
62
+ return
63
+ raise InvalidEnvelope(
64
+ "Expected JSON with string keys and safe integers; floats are unsupported"
65
+ )
66
+
67
+ validate(value)
68
+ try:
69
+ result = json.dumps(
70
+ value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
71
+ ).encode("utf-8")
72
+ except (ValueError, UnicodeError, TypeError) as exc:
73
+ raise InvalidEnvelope("Invalid UTF-8 JSON") from exc
74
+ if len(result) > 2 * 1024 * 1024:
75
+ raise InvalidEnvelope("Envelope exceeds 2 MiB")
76
+ return result
77
+
78
+
79
+ def loads(data: Any) -> Any:
80
+ def unique(pairs: Any) -> dict:
81
+ out: dict = {}
82
+ for k, v in pairs:
83
+ if k in out:
84
+ raise InvalidEnvelope("Duplicate JSON key")
85
+ out[k] = v
86
+ return out
87
+
88
+ try:
89
+ value = json.loads(data, object_pairs_hook=unique)
90
+ canonical(value)
91
+ return value
92
+ except (ValueError, TypeError, UnicodeError, RecursionError) as exc:
93
+ raise InvalidEnvelope("Invalid JSON") from exc
94
+
95
+
96
+ def digest(value: Any) -> str:
97
+ return hashlib.sha256(canonical(value)).hexdigest()
98
+
99
+
100
+ def _hash(value: str) -> bool:
101
+ return type(value) is str and len(value) == 64 and all(c in "0123456789abcdef" for c in value)
102
+
103
+
104
+ def secure_dir(path: Path) -> Path:
105
+ path = path.expanduser().absolute()
106
+ # Resolve operator-chosen ancestors (macOS /tmp), reject a symlink leaf.
107
+ if path.is_symlink():
108
+ raise InvalidEnvelope("State directory must not be a symlink")
109
+ path = path.resolve()
110
+ path.mkdir(parents=True, mode=0o700, exist_ok=True)
111
+ if not path.is_dir() or path.stat().st_uid != os.getuid() or path.stat().st_mode & 0o077:
112
+ raise InvalidEnvelope("State directory must be owned by this user with mode 0700")
113
+ return path
114
+
115
+
116
+ def write_exclusive(path: Path, value: Any) -> None:
117
+ fd = os.open(
118
+ str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0), 0o600
119
+ )
120
+ with os.fdopen(fd, "wb") as f:
121
+ f.write(canonical(value) + b"\n")
122
+ f.flush()
123
+ os.fsync(f.fileno())
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class ActionEnvelope:
128
+ capability: str
129
+ params: Dict[str, Any]
130
+ intent: str = ""
131
+ actor: str = "local"
132
+ _bytes: bytes = field(init=False, repr=False, compare=False)
133
+
134
+ def __post_init__(self) -> None:
135
+ if (
136
+ not self.capability
137
+ or type(self.capability) is not str
138
+ or type(self.params) is not dict
139
+ or type(self.intent) is not str
140
+ or type(self.actor) is not str
141
+ ):
142
+ raise InvalidEnvelope("Invalid action fields")
143
+ object.__setattr__(
144
+ self,
145
+ "_bytes",
146
+ canonical(
147
+ {
148
+ "capability": self.capability,
149
+ "params": self.params,
150
+ "intent": self.intent,
151
+ "actor": self.actor,
152
+ }
153
+ ),
154
+ )
155
+ object.__setattr__(self, "params", loads(canonical(self.params)))
156
+
157
+ def to_dict(self) -> dict:
158
+ return loads(self._bytes)
159
+
160
+ @property
161
+ def action_hash(self) -> str:
162
+ return hashlib.sha256(self._bytes).hexdigest()
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class BindingCondition:
167
+ """Exact action plus an independently supplied state object that must hold."""
168
+
169
+ action_hash: str
170
+ required_state: Dict[str, Any]
171
+ name: str = "explicit-prerequisite"
172
+ _bytes: bytes = field(init=False, repr=False, compare=False)
173
+
174
+ def __post_init__(self) -> None:
175
+ if (
176
+ not _hash(self.action_hash)
177
+ or type(self.required_state) is not dict
178
+ or not self.required_state
179
+ or not self.name
180
+ or type(self.name) is not str
181
+ ):
182
+ raise InvalidEnvelope("Condition requires an exact action and nonempty required state")
183
+ object.__setattr__(
184
+ self,
185
+ "_bytes",
186
+ canonical(
187
+ {
188
+ "action_hash": self.action_hash,
189
+ "required_state": self.required_state,
190
+ "name": self.name,
191
+ }
192
+ ),
193
+ )
194
+ object.__setattr__(self, "required_state", loads(canonical(self.required_state)))
195
+
196
+ def to_dict(self) -> dict:
197
+ return loads(self._bytes)
198
+
199
+ @property
200
+ def condition_hash(self) -> str:
201
+ return hashlib.sha256(self._bytes).hexdigest()
202
+
203
+ def evaluate(self, action: ActionEnvelope, state: dict) -> bool:
204
+ return self.to_dict()["action_hash"] == action.action_hash and canonical(
205
+ state
206
+ ) == canonical(self.to_dict()["required_state"])
207
+
208
+
209
+ @dataclass(frozen=True)
210
+ class Decision:
211
+ payload: dict
212
+ signature: str
213
+ _bytes: bytes = field(init=False, repr=False, compare=False)
214
+
215
+ def __post_init__(self) -> None:
216
+ object.__setattr__(self, "_bytes", canonical(self.payload))
217
+ object.__setattr__(self, "payload", loads(self._bytes))
218
+
219
+ @property
220
+ def allowed(self) -> bool:
221
+ # Convenience only. Execution MUST independently verify the signed decision.
222
+ return loads(self._bytes).get("allowed") is True
223
+
224
+ def to_dict(self) -> dict:
225
+ return {"payload": loads(self._bytes), "signature": self.signature}
226
+
227
+ @classmethod
228
+ def from_dict(cls, value: dict) -> Decision:
229
+ if type(value) is not dict or set(value) != {"payload", "signature"}:
230
+ raise InvalidEnvelope("Invalid signed envelope")
231
+ return cls(value["payload"], value["signature"])
232
+
233
+
234
+ DecisionReceipt = Decision
235
+
236
+
237
+ class ContinuationAuthority(Protocol):
238
+ @property
239
+ def public_key(self) -> str: ...
240
+ def authorize(
241
+ self,
242
+ *,
243
+ condition: Optional[BindingCondition],
244
+ action: ActionEnvelope,
245
+ state: dict,
246
+ ttl: int = 300,
247
+ ) -> Decision: ...
248
+
249
+
250
+ class LocalAuthority:
251
+ def __init__(self, signing_key: Optional[SigningKey] = None):
252
+ self._key = signing_key or SigningKey.generate()
253
+
254
+ @property
255
+ def public_key(self) -> str:
256
+ return base64.b64encode(bytes(self._key.verify_key)).decode("ascii")
257
+
258
+ @classmethod
259
+ def load(cls, root: Path) -> LocalAuthority:
260
+ root = secure_dir(root)
261
+ path = root / "authority.key"
262
+ if not path.exists():
263
+ try:
264
+ write_exclusive(
265
+ path, {"seed": base64.b64encode(bytes(SigningKey.generate())).decode("ascii")}
266
+ )
267
+ except FileExistsError:
268
+ pass
269
+ if path.is_symlink() or path.stat().st_mode & 0o077 or path.stat().st_uid != os.getuid():
270
+ raise InvalidEnvelope("Unsafe signing key")
271
+ return cls(SigningKey(base64.b64decode(loads(path.read_bytes())["seed"], validate=True)))
272
+
273
+ def sign(self, payload: dict) -> Decision:
274
+ signature = base64.b64encode(self._key.sign(canonical(payload)).signature).decode("ascii")
275
+ return Decision(payload, signature)
276
+
277
+ def authorize(
278
+ self,
279
+ *,
280
+ condition: Optional[BindingCondition],
281
+ action: ActionEnvelope,
282
+ state: dict,
283
+ ttl: int = 300,
284
+ ) -> Decision:
285
+ if type(ttl) is not int or not 1 <= ttl <= 3600:
286
+ raise InvalidEnvelope("TTL must be an integer from 1 to 3600 seconds")
287
+ now = int(time.time())
288
+ allowed = condition is not None and condition.evaluate(action, state)
289
+ return self.sign(
290
+ {
291
+ "schema": "swiftapi.decision/v1",
292
+ "issuer": self.public_key,
293
+ "allowed": allowed,
294
+ "action_hash": action.action_hash,
295
+ "capability": action.capability,
296
+ "condition_hash": condition.condition_hash if condition else None,
297
+ "state_hash": digest(state),
298
+ "nonce": secrets.token_hex(32),
299
+ "issued_at": now,
300
+ "expires_at": now + ttl,
301
+ "reason": "prerequisite satisfied"
302
+ if allowed
303
+ else "prerequisite absent or unsatisfied",
304
+ }
305
+ )
306
+
307
+
308
+ def verify_receipt(receipt: Any, public_key: str) -> dict:
309
+ """Verify with an independently trusted key, never a key supplied by the receipt."""
310
+ try:
311
+ d = receipt if isinstance(receipt, Decision) else Decision.from_dict(receipt)
312
+ value = d.to_dict()
313
+ VerifyKey(base64.b64decode(public_key, validate=True)).verify(
314
+ canonical(value["payload"]), base64.b64decode(value["signature"], validate=True)
315
+ )
316
+ p = value["payload"]
317
+ if p.get("issuer") != public_key or p.get("schema") not in {
318
+ "swiftapi.decision/v1",
319
+ "swiftapi.execution/v1",
320
+ }:
321
+ raise ValueError("schema or issuer")
322
+ return p
323
+ except (ValueError, KeyError, TypeError, BadSignatureError) as exc:
324
+ raise AuthorizationDenied("Invalid signature, issuer, or receipt schema") from exc
325
+
326
+
327
+ def verify_decision(
328
+ decision: Decision,
329
+ public_key: str,
330
+ action: ActionEnvelope,
331
+ condition: BindingCondition,
332
+ state: dict,
333
+ ) -> dict:
334
+ p = verify_receipt(decision, public_key)
335
+ expected = {
336
+ "schema",
337
+ "issuer",
338
+ "allowed",
339
+ "action_hash",
340
+ "capability",
341
+ "condition_hash",
342
+ "state_hash",
343
+ "nonce",
344
+ "issued_at",
345
+ "expires_at",
346
+ "reason",
347
+ }
348
+ if set(p) != expected or p["schema"] != "swiftapi.decision/v1" or p["allowed"] is not True:
349
+ raise AuthorizationDenied("Authorization absent or denied")
350
+ now = int(time.time())
351
+ if (
352
+ type(p["issued_at"]) is not int
353
+ or type(p["expires_at"]) is not int
354
+ or not p["issued_at"] <= now < p["expires_at"]
355
+ or not 0 < p["expires_at"] - p["issued_at"] <= 3600
356
+ ):
357
+ raise AuthorizationDenied("Authorization expired, future dated, or invalid")
358
+ if (
359
+ not _hash(p["nonce"])
360
+ or p["action_hash"] != action.action_hash
361
+ or p["capability"] != action.capability
362
+ or p["condition_hash"] != condition.condition_hash
363
+ or p["state_hash"] != digest(state)
364
+ or not condition.evaluate(action, state)
365
+ ):
366
+ raise AuthorizationDenied("Action, capability, condition, or state binding mismatch")
367
+ return p
368
+
369
+
370
+ class ReplayLedger:
371
+ """Durable atomic claim BEFORE execution. Failures consume the nonce too."""
372
+
373
+ def __init__(self, root: Path):
374
+ self.root = secure_dir(root)
375
+ self.path = self.root / "replay.sqlite3"
376
+ if self.path.is_symlink():
377
+ raise InvalidEnvelope("Replay database may not be a symlink")
378
+ fd = os.open(str(self.path), os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600)
379
+ os.close(fd)
380
+ with sqlite3.connect(str(self.path), timeout=10) as db:
381
+ db.execute(
382
+ "CREATE TABLE IF NOT EXISTS used (issuer TEXT NOT NULL, nonce TEXT NOT NULL, claimed INTEGER NOT NULL, PRIMARY KEY(issuer,nonce))"
383
+ )
384
+
385
+ def claim(self, issuer: str, nonce: str) -> None:
386
+ try:
387
+ with sqlite3.connect(str(self.path), timeout=10) as db:
388
+ db.execute("PRAGMA synchronous=FULL")
389
+ db.execute("INSERT INTO used VALUES (?,?,?)", (issuer, nonce, int(time.time())))
390
+ except sqlite3.IntegrityError as exc:
391
+ raise ReplayError("Authorization already consumed") from exc
392
+
393
+
394
+ class LocalGuard:
395
+ """For trusted application callbacks; callback internals remain the caller's responsibility."""
396
+
397
+ def __init__(self, authority: ContinuationAuthority, root: Path):
398
+ self.authority = authority
399
+ self.public_key = authority.public_key
400
+ self.ledger = ReplayLedger(root)
401
+
402
+ def run(
403
+ self,
404
+ function: Callable[[], Any],
405
+ *,
406
+ action: str,
407
+ intent: str,
408
+ condition: Optional[BindingCondition],
409
+ params: Optional[dict] = None,
410
+ state: Optional[dict] = None,
411
+ ) -> Any:
412
+ envelope = ActionEnvelope(action, params or {}, intent)
413
+ current = loads(canonical(state or {}))
414
+ decision = self.authority.authorize(condition=condition, action=envelope, state=current)
415
+ if condition is None:
416
+ raise AuthorizationDenied("Explicit condition required")
417
+ p = verify_decision(decision, self.public_key, envelope, condition, current)
418
+ write_exclusive(self.ledger.root / (p["nonce"] + ".json"), decision.to_dict())
419
+ self.ledger.claim(p["issuer"], p["nonce"])
420
+ return function()
@@ -0,0 +1,268 @@
1
+ """Reversible MCP + instruction installation. Does not alter host permissions."""
2
+
3
+ from __future__ import annotations
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import shutil
9
+ import sys
10
+ import tempfile
11
+ from typing import Optional
12
+ from .core import InvalidEnvelope, secure_dir, loads
13
+
14
+ SERVER = "swiftapi_brake"
15
+ BEGIN = "# BEGIN SWIFTAPI BRAKE MANAGED"
16
+ END = "# END SWIFTAPI BRAKE MANAGED"
17
+ GUIDANCE = """# BEGIN SWIFTAPI BRAKE MANAGED
18
+ SwiftAPI supplies a local prerequisite gate for its registered capabilities.
19
+ Use swiftapi_brake status to discover its sandbox, propose_marker to request an
20
+ exact marker, and execute to attempt it. A GATE_BLOCK is a denied tool action,
21
+ not model-native silence. Approval is performed by the operator with
22
+ `swiftapi brake authorize REQUEST_ID` in a separate terminal. Never self-approve
23
+ or edit authority state to bypass a denial. Existing governing instructions
24
+ still apply. Arbitrary tools and host shell commands are outside this broker.
25
+ Do not send provider keys, credentials, or secrets to the broker or model context.
26
+ # END SWIFTAPI BRAKE MANAGED
27
+ """
28
+
29
+
30
+ def _sha(data: bytes) -> str:
31
+ return hashlib.sha256(data).hexdigest()
32
+
33
+
34
+ def _read(path: Path) -> bytes:
35
+ if path.is_symlink() or (path.exists() and not path.is_file()):
36
+ raise InvalidEnvelope("Refusing nonregular configuration: " + str(path))
37
+ return path.read_bytes() if path.exists() else b""
38
+
39
+
40
+ def _atomic(path: Path, data: bytes) -> None:
41
+ _read(path)
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ fd, name = tempfile.mkstemp(prefix=".swiftapi-", dir=path.parent)
44
+ try:
45
+ mode = path.stat().st_mode & 0o777 if path.exists() else 0o600
46
+ os.fchmod(fd, mode)
47
+ with os.fdopen(fd, "wb") as f:
48
+ f.write(data)
49
+ f.flush()
50
+ os.fsync(f.fileno())
51
+ os.replace(name, path)
52
+ finally:
53
+ if os.path.exists(name):
54
+ os.unlink(name)
55
+
56
+
57
+ def _toml(data: bytes) -> dict:
58
+ try:
59
+ import tomllib
60
+ except ImportError:
61
+ import tomli as tomllib
62
+ return tomllib.loads(data.decode("utf-8"))
63
+
64
+
65
+ def _hosts(host: Optional[str], home: Path) -> list:
66
+ if host:
67
+ if host not in ("codex", "claude"):
68
+ raise InvalidEnvelope("Supported hosts: codex, claude")
69
+ return [host]
70
+ result = [h for h in ("codex", "claude") if shutil.which(h) or (home / ("." + h)).exists()]
71
+ if not result:
72
+ raise InvalidEnvelope("No host detected; specify codex or claude")
73
+ return result
74
+
75
+
76
+ def install(host: Optional[str] = None, dry_run: bool = False, home: Optional[Path] = None) -> dict:
77
+ home = Path(home or os.environ.get("SWIFTAPI_CONFIG_HOME") or Path.home()).absolute()
78
+ state = home / ".local/state/swiftapi-install"
79
+ python = sys.executable # Preserve venv executable; resolving it would discard the venv.
80
+ changes = []
81
+ records = []
82
+ for h in _hosts(host, home):
83
+ receipt_path = state / (h + ".json")
84
+ receipt = loads(_read(receipt_path)) if receipt_path.exists() else None
85
+ config = home / (".codex/config.toml" if h == "codex" else ".claude.json")
86
+ instructions = home / (".codex/AGENTS.md" if h == "codex" else ".claude/CLAUDE.md")
87
+ old = _read(config)
88
+ text = _read(instructions)
89
+ entry = {"command": python, "args": ["-m", "swiftapi", "mcp"]}
90
+ fragment = ("\n\n" + GUIDANCE).encode()
91
+ if h == "codex":
92
+ parsed = _toml(old)
93
+ present = parsed.get("mcp_servers", {}).get(SERVER)
94
+ block = (
95
+ "\n"
96
+ + BEGIN
97
+ + "\n[mcp_servers."
98
+ + SERVER
99
+ + "]\ncommand = "
100
+ + json.dumps(python)
101
+ + '\nargs = ["-m", "swiftapi", "mcp"]\n'
102
+ + END
103
+ + "\n"
104
+ ).encode()
105
+ if BEGIN.encode() in old and (not receipt or old.count(block) != 1):
106
+ raise InvalidEnvelope("Unowned/modified managed Codex block")
107
+ new = old if present == entry and receipt else old + block
108
+ _toml(new)
109
+ else:
110
+ parsed = json.loads(old) if old else {}
111
+ if not isinstance(parsed, dict) or not isinstance(parsed.get("mcpServers", {}), dict):
112
+ raise InvalidEnvelope("Invalid Claude configuration")
113
+ present = parsed.get("mcpServers", {}).get(SERVER)
114
+ parsed.setdefault("mcpServers", {})[SERVER] = entry
115
+ # Claude rewrites its own JSON formatting during normal use. If our
116
+ # owned entry is intact, preserve those bytes on repeated installs.
117
+ new = (
118
+ old
119
+ if present == entry and receipt
120
+ else (json.dumps(parsed, indent=2, ensure_ascii=False) + "\n").encode()
121
+ )
122
+ block = b""
123
+ if present is not None and (not receipt or present != receipt["entry"] or present != entry):
124
+ raise InvalidEnvelope(
125
+ "Unowned/modified SwiftAPI MCP entry; uninstall the owned version first"
126
+ )
127
+ if BEGIN.encode() in text:
128
+ if not receipt or text.count(fragment) != 1:
129
+ raise InvalidEnvelope("Unowned/modified instruction block")
130
+ instruction_new = text
131
+ else:
132
+ instruction_new = text + fragment
133
+ if h == "codex" and (home / ".codex/AGENTS.override.md").exists():
134
+ raise InvalidEnvelope(
135
+ "AGENTS.override.md overrides global guidance; resolve it before installing"
136
+ )
137
+ if receipt and receipt.get("status") == "installed":
138
+ if new == old and instruction_new == text:
139
+ changes.append({"host": h, "status": "already installed", "paths": []})
140
+ continue
141
+ records.append(
142
+ (h, config, old, new, instructions, text, instruction_new, entry, block, fragment)
143
+ )
144
+ changes.append(
145
+ {
146
+ "host": h,
147
+ "status": "planned",
148
+ "paths": [str(config), str(instructions)],
149
+ "mcp_entry": entry,
150
+ "instruction_added": GUIDANCE,
151
+ }
152
+ )
153
+ if dry_run:
154
+ return {"dry_run": True, "changes": changes, "host_permissions_changed": False}
155
+ if records:
156
+ secure_dir(state)
157
+ for h, config, old, new, ins, text, ins_new, entry, block, fragment in records:
158
+ backup = secure_dir(state / h)
159
+ for label, data in (("config", old), ("instructions", text)):
160
+ p = backup / (label + ".bak")
161
+ if not p.exists():
162
+ fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
163
+ with os.fdopen(fd, "wb") as f:
164
+ f.write(data)
165
+ receipt = {
166
+ "status": "installed",
167
+ "host": h,
168
+ "entry": entry,
169
+ "config": str(config),
170
+ "instructions": str(ins),
171
+ "config_existed": config.exists(),
172
+ "instructions_existed": ins.exists(),
173
+ "block": block.decode(),
174
+ "fragment": fragment.decode(),
175
+ "config_before_sha256": _sha(old),
176
+ "config_after_sha256": _sha(new),
177
+ "instructions_before_sha256": _sha(text),
178
+ "instructions_after_sha256": _sha(ins_new),
179
+ }
180
+ # Ownership is written first so an interrupted installation is recoverable.
181
+ _atomic(state / (h + ".json"), json.dumps(receipt, indent=2).encode())
182
+ _atomic(config, new)
183
+ _atomic(ins, ins_new)
184
+ return {
185
+ "dry_run": False,
186
+ "changes": changes,
187
+ "status": "installed",
188
+ "restart_required": True,
189
+ "backup_directory": str(state),
190
+ "host_permissions_changed": False,
191
+ }
192
+
193
+
194
+ def uninstall(
195
+ host: Optional[str] = None, dry_run: bool = False, home: Optional[Path] = None
196
+ ) -> dict:
197
+ home = Path(home or os.environ.get("SWIFTAPI_CONFIG_HOME") or Path.home()).absolute()
198
+ state = home / ".local/state/swiftapi-install"
199
+ result = []
200
+ for h in [host] if host else ["codex", "claude"]:
201
+ if h not in ("codex", "claude"):
202
+ raise InvalidEnvelope("Unknown host")
203
+ rp = state / (h + ".json")
204
+ if not rp.exists():
205
+ result.append({"host": h, "status": "not installed"})
206
+ continue
207
+ r = loads(_read(rp))
208
+ if r["status"] == "uninstalled":
209
+ result.append({"host": h, "status": "not installed"})
210
+ continue
211
+ cfg, ins = Path(r["config"]), Path(r["instructions"])
212
+ old, text = _read(cfg), _read(ins)
213
+ if h == "codex":
214
+ block = r["block"].encode()
215
+ if old.count(block) != 1:
216
+ raise InvalidEnvelope("Managed config changed; preserve it for manual review")
217
+ new = old.replace(block, b"", 1)
218
+ else:
219
+ obj = json.loads(old)
220
+ if obj.get("mcpServers", {}).get(SERVER) != r["entry"]:
221
+ raise InvalidEnvelope("Managed config changed; preserve it for manual review")
222
+ del obj["mcpServers"][SERVER]
223
+ new = (json.dumps(obj, indent=2, ensure_ascii=False) + "\n").encode()
224
+ fragment = r["fragment"].encode()
225
+ if text.count(fragment) != 1:
226
+ raise InvalidEnvelope("Managed instructions changed; preserve them for manual review")
227
+ remaining = text.replace(fragment, b"", 1)
228
+ if not dry_run:
229
+ _atomic(cfg, new)
230
+ _atomic(ins, remaining)
231
+ r["status"] = "uninstalled"
232
+ _atomic(rp, json.dumps(r, indent=2).encode())
233
+ result.append({"host": h, "status": "planned removal" if dry_run else "uninstalled"})
234
+ return {"dry_run": dry_run, "changes": result, "receipts_retained": True}
235
+
236
+
237
+ def doctor(home: Optional[Path] = None) -> dict:
238
+ home = Path(home or os.environ.get("SWIFTAPI_CONFIG_HOME") or Path.home())
239
+ checks = {}
240
+ for h in ("codex", "claude"):
241
+ rp = home / ".local/state/swiftapi-install" / (h + ".json")
242
+ check: dict = {
243
+ "cli_found": bool(shutil.which(h)),
244
+ "installed": False,
245
+ "runtime_qualified": False,
246
+ }
247
+ if rp.exists():
248
+ r = loads(_read(rp))
249
+ try:
250
+ data = _read(Path(r["config"]))
251
+ obj = _toml(data) if h == "codex" else json.loads(data)
252
+ present = obj.get("mcp_servers" if h == "codex" else "mcpServers", {}).get(SERVER)
253
+ check["installed"] = (
254
+ r["status"] == "installed"
255
+ and present == r["entry"]
256
+ and r["fragment"].encode() in _read(Path(r["instructions"]))
257
+ )
258
+ check["interpreter_exists"] = Path(r["entry"]["command"]).is_file()
259
+ except (ValueError, OSError):
260
+ check["error"] = "configuration invalid"
261
+ checks[h] = check
262
+ return {
263
+ "version": "1.3.0",
264
+ "python": sys.version.split()[0],
265
+ "local_brake": "available",
266
+ "hosts": checks,
267
+ "scope": "MCP broker capabilities only; host tools are outside this boundary",
268
+ }