swiftapi-python 1.2.2__py3-none-any.whl → 1.3.1__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/__init__.py +75 -96
- swiftapi/__main__.py +2 -0
- swiftapi/anthropic.py +236 -222
- swiftapi/brake/__init__.py +45 -0
- swiftapi/brake/benchmarks.py +111 -0
- swiftapi/brake/broker.py +274 -0
- swiftapi/brake/core.py +420 -0
- swiftapi/brake/install.py +268 -0
- swiftapi/brake/mcp.py +160 -0
- swiftapi/brake/observations.py +61 -0
- swiftapi/brake/qualification.py +116 -0
- swiftapi/brake/remote.py +90 -0
- swiftapi/cli.py +150 -0
- swiftapi/client.py +328 -309
- swiftapi/enforcement.py +281 -280
- swiftapi/openai.py +243 -225
- swiftapi/py.typed +0 -0
- swiftapi/verifier.py +182 -130
- swiftapi_python-1.3.1.dist-info/METADATA +144 -0
- swiftapi_python-1.3.1.dist-info/RECORD +26 -0
- {swiftapi_python-1.2.2.dist-info → swiftapi_python-1.3.1.dist-info}/WHEEL +1 -1
- swiftapi_python-1.3.1.dist-info/entry_points.txt +2 -0
- swiftapi_python-1.2.2.dist-info/METADATA +0 -255
- swiftapi_python-1.2.2.dist-info/RECORD +0 -13
- {swiftapi_python-1.2.2.dist-info → swiftapi_python-1.3.1.dist-info}/licenses/LICENSE +0 -0
- {swiftapi_python-1.2.2.dist-info → swiftapi_python-1.3.1.dist-info}/top_level.txt +0 -0
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.1",
|
|
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
|
+
}
|