banto 5.2.2__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.
- banto/__init__.py +33 -0
- banto/__main__.py +560 -0
- banto/backend.py +45 -0
- banto/broker.py +314 -0
- banto/broker_cli.py +40 -0
- banto/broker_client.py +69 -0
- banto/broker_mcp.py +54 -0
- banto/broker_setup.py +121 -0
- banto/chatgpt.py +186 -0
- banto/config.json +43 -0
- banto/ecdsa_p256.py +127 -0
- banto/guard.py +887 -0
- banto/keychain.py +260 -0
- banto/kimaru_evaluation.py +366 -0
- banto/kimaru_material.py +278 -0
- banto/lease.py +292 -0
- banto/lease_cli.py +150 -0
- banto/mcp_server.py +764 -0
- banto/pricing.json +75 -0
- banto/profiles.py +147 -0
- banto/register_popup.py +1383 -0
- banto/rotation_capabilities.json +361 -0
- banto/sealed_cli.py +70 -0
- banto/sealed_signer.py +468 -0
- banto/sync/__init__.py +13 -0
- banto/sync/audit.py +46 -0
- banto/sync/browser_batch.py +320 -0
- banto/sync/browser_issuer.py +831 -0
- banto/sync/browser_recorder.py +472 -0
- banto/sync/capabilities.py +173 -0
- banto/sync/cli.py +4076 -0
- banto/sync/cloudflare_token_rotator.py +397 -0
- banto/sync/config.py +247 -0
- banto/sync/drivers/__init__.py +97 -0
- banto/sync/drivers/alibaba.py +114 -0
- banto/sync/drivers/aws_sm.py +113 -0
- banto/sync/drivers/aws_ssm.py +96 -0
- banto/sync/drivers/azure.py +81 -0
- banto/sync/drivers/azure_devops.py +100 -0
- banto/sync/drivers/base.py +29 -0
- banto/sync/drivers/bitbucket.py +132 -0
- banto/sync/drivers/circleci.py +99 -0
- banto/sync/drivers/cloudflare.py +61 -0
- banto/sync/drivers/deno.py +73 -0
- banto/sync/drivers/digitalocean.py +75 -0
- banto/sync/drivers/docker.py +66 -0
- banto/sync/drivers/flyio.py +66 -0
- banto/sync/drivers/gcp.py +81 -0
- banto/sync/drivers/github.py +57 -0
- banto/sync/drivers/gitlab.py +84 -0
- banto/sync/drivers/hasura.py +127 -0
- banto/sync/drivers/heroku.py +61 -0
- banto/sync/drivers/huawei.py +109 -0
- banto/sync/drivers/jdcloud.py +95 -0
- banto/sync/drivers/kubernetes.py +105 -0
- banto/sync/drivers/laravel_forge.py +141 -0
- banto/sync/drivers/local.py +130 -0
- banto/sync/drivers/naver.py +106 -0
- banto/sync/drivers/netlify.py +68 -0
- banto/sync/drivers/nhn.py +122 -0
- banto/sync/drivers/railway.py +72 -0
- banto/sync/drivers/render.py +172 -0
- banto/sync/drivers/sakura.py +117 -0
- banto/sync/drivers/supabase.py +76 -0
- banto/sync/drivers/tencent.py +109 -0
- banto/sync/drivers/terraform.py +137 -0
- banto/sync/drivers/vercel.py +164 -0
- banto/sync/drivers/volcengine.py +97 -0
- banto/sync/google_rotator.py +637 -0
- banto/sync/history.py +208 -0
- banto/sync/incident_report.py +159 -0
- banto/sync/notifiers/__init__.py +34 -0
- banto/sync/notifiers/base.py +43 -0
- banto/sync/notifiers/datadog.py +66 -0
- banto/sync/notifiers/pagerduty.py +73 -0
- banto/sync/notifiers/slack.py +57 -0
- banto/sync/notifiers/teams.py +59 -0
- banto/sync/openai_rotator.py +526 -0
- banto/sync/propagation.py +269 -0
- banto/sync/setup.py +239 -0
- banto/sync/smoke_presets.py +153 -0
- banto/sync/stripe_webhook_rotator.py +516 -0
- banto/sync/sync.py +277 -0
- banto/sync/sync_state.py +116 -0
- banto/sync/validate.py +337 -0
- banto/sync/vercel_inventory.py +185 -0
- banto/sync/web.py +1881 -0
- banto/sync/x_oauth_pkce.py +294 -0
- banto/sync/x_oauth_rotator.py +284 -0
- banto/sync/xai_rotator.py +542 -0
- banto/vault.py +364 -0
- banto/widgets.py +566 -0
- banto-5.2.2.dist-info/METADATA +713 -0
- banto-5.2.2.dist-info/RECORD +98 -0
- banto-5.2.2.dist-info/WHEEL +5 -0
- banto-5.2.2.dist-info/entry_points.txt +3 -0
- banto-5.2.2.dist-info/licenses/LICENSE +93 -0
- banto-5.2.2.dist-info/top_level.txt +1 -0
banto/broker.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""One macOS process owns Keychain operations; clients receive results only.
|
|
2
|
+
|
|
3
|
+
The Unix socket is a same-user boundary, not an app-signature sandbox. There is
|
|
4
|
+
deliberately no get-secret, arbitrary URL, shell, import, or environment API.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextvars
|
|
11
|
+
import fcntl
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import plistlib
|
|
17
|
+
import socket
|
|
18
|
+
import socketserver
|
|
19
|
+
import struct
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
|
|
26
|
+
from .broker_client import (BrokerClient, BrokerError, MAX_MESSAGE, check_private,
|
|
27
|
+
encode_message, read_message, runtime_dir, socket_path)
|
|
28
|
+
|
|
29
|
+
IN_SERVICE = False
|
|
30
|
+
_observed = contextvars.ContextVar("banto_broker_secrets", default=None)
|
|
31
|
+
LABEL = "work.allnew.banto.broker"
|
|
32
|
+
MCP_OPERATIONS = (
|
|
33
|
+
"banto_sync_status", "banto_sync_push", "banto_sync_audit", "banto_validate",
|
|
34
|
+
"banto_validate_keychain", "banto_budget_status", "banto_sync_setup",
|
|
35
|
+
"banto_register_key", "banto_lease_list", "banto_lease_cleanup",
|
|
36
|
+
"banto_sealed_create_key", "banto_sealed_export_pubkey", "banto_sealed_sign",
|
|
37
|
+
"banto_sealed_list",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def require_service() -> None:
|
|
42
|
+
"""Fail closed for legacy library calls once this machine enables the broker.
|
|
43
|
+
|
|
44
|
+
This prevents accidental direct reads, not hostile code under the same UID.
|
|
45
|
+
"""
|
|
46
|
+
if not IN_SERVICE and (runtime_dir() / "required").exists():
|
|
47
|
+
raise BrokerError("direct_keychain_disabled; use BrokerClient operations or banto register")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def observe_secret(value: str) -> None:
|
|
51
|
+
values = _observed.get()
|
|
52
|
+
if values is not None:
|
|
53
|
+
values.add(value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
57
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def api_request(provider: str, payload: dict) -> dict:
|
|
62
|
+
"""Non-streaming generation at fixed official endpoints; no caller auth/URL."""
|
|
63
|
+
if provider not in ("openai", "anthropic") or not isinstance(payload, dict):
|
|
64
|
+
raise BrokerError("unsupported_request")
|
|
65
|
+
allowed = ({"model", "input", "instructions", "max_output_tokens", "temperature",
|
|
66
|
+
"top_p", "reasoning", "text", "store"} if provider == "openai" else
|
|
67
|
+
{"model", "messages", "max_tokens", "system", "temperature", "top_p",
|
|
68
|
+
"top_k", "stop_sequences", "thinking"})
|
|
69
|
+
if set(payload) - allowed or not isinstance(payload.get("model"), str):
|
|
70
|
+
raise BrokerError("unsupported_payload_fields")
|
|
71
|
+
from .vault import SecureVault
|
|
72
|
+
if SecureVault().budget_enabled:
|
|
73
|
+
raise BrokerError("budget_enabled; generation adapter must support hold/settle before use")
|
|
74
|
+
from .keychain import KeychainStore
|
|
75
|
+
from .sync.config import SyncConfig
|
|
76
|
+
key = KeychainStore(service_prefix=SyncConfig.load().keychain_service).get(provider)
|
|
77
|
+
if not key:
|
|
78
|
+
raise BrokerError("key_unavailable; register or approve access locally")
|
|
79
|
+
headers = {"Content-Type": "application/json"}
|
|
80
|
+
if provider == "openai":
|
|
81
|
+
url = "https://api.openai.com/v1/responses"
|
|
82
|
+
headers["Authorization"] = "Bearer " + key
|
|
83
|
+
payload = {**payload, "store": False}
|
|
84
|
+
else:
|
|
85
|
+
url = "https://api.anthropic.com/v1/messages"
|
|
86
|
+
headers.update({"x-api-key": key, "anthropic-version": "2023-06-01"})
|
|
87
|
+
# Ignore ambient proxy settings and never forward auth through redirects.
|
|
88
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
|
|
89
|
+
request = urllib.request.Request(url, json.dumps(payload).encode(), headers, method="POST")
|
|
90
|
+
try:
|
|
91
|
+
with opener.open(request, timeout=120) as response:
|
|
92
|
+
raw = response.read(MAX_MESSAGE + 1)
|
|
93
|
+
if len(raw) > MAX_MESSAGE:
|
|
94
|
+
raise BrokerError("upstream_response_too_large")
|
|
95
|
+
result = json.loads(raw)
|
|
96
|
+
# Filter after decoding, so JSON escapes cannot defeat redaction.
|
|
97
|
+
return redact(result, {key})
|
|
98
|
+
except urllib.error.HTTPError as error:
|
|
99
|
+
error.close()
|
|
100
|
+
return {"error": "upstream_http_error", "status": error.code}
|
|
101
|
+
except (OSError, ValueError):
|
|
102
|
+
raise BrokerError("upstream_failed_or_outcome_unknown") from None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def redact(value, values: set[str]):
|
|
106
|
+
if isinstance(value, str):
|
|
107
|
+
for secret in sorted(values, key=len, reverse=True):
|
|
108
|
+
if secret:
|
|
109
|
+
value = value.replace(secret, "[REDACTED]")
|
|
110
|
+
return value
|
|
111
|
+
if isinstance(value, dict):
|
|
112
|
+
return {redact(k, values): redact(v, values) for k, v in value.items()}
|
|
113
|
+
if isinstance(value, list):
|
|
114
|
+
return [redact(v, values) for v in value]
|
|
115
|
+
return value
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _dispatch(operation: str, arguments: dict) -> dict:
|
|
119
|
+
if operation == "kimaru_evaluation_azure_preflight" and not arguments:
|
|
120
|
+
from .kimaru_evaluation import azure_preflight
|
|
121
|
+
return azure_preflight()
|
|
122
|
+
if operation == "kimaru_evaluation_preflight" and not arguments:
|
|
123
|
+
from .kimaru_evaluation import preflight
|
|
124
|
+
return preflight()
|
|
125
|
+
if operation == "kimaru_evaluation_capabilities" and not arguments:
|
|
126
|
+
from .kimaru_evaluation import capabilities
|
|
127
|
+
return capabilities()
|
|
128
|
+
if operation == "kimaru_evaluation_prepare":
|
|
129
|
+
if set(arguments) != {"candidate_sha", "limit_usd", "expires_at", "owner_confirm", "gemini_paid_tier_confirmed"}:
|
|
130
|
+
raise BrokerError("kimaru_evaluation_arguments_invalid")
|
|
131
|
+
from .kimaru_evaluation import prepare
|
|
132
|
+
return prepare(**arguments)
|
|
133
|
+
if operation == "kimaru_evaluation_generate":
|
|
134
|
+
if set(arguments) != {"provider", "payload", "request_id", "case_id", "candidate_sha"}:
|
|
135
|
+
raise BrokerError("kimaru_evaluation_arguments_invalid")
|
|
136
|
+
from .kimaru_evaluation import generate
|
|
137
|
+
return generate(**arguments)
|
|
138
|
+
if operation == "kimaru_material_capabilities" and not arguments:
|
|
139
|
+
from .kimaru_material import capabilities
|
|
140
|
+
return capabilities()
|
|
141
|
+
if operation == "kimaru_material_provision":
|
|
142
|
+
if set(arguments) != {"environment", "purpose", "candidate_sha", "approved_region", "owner_confirm"}:
|
|
143
|
+
raise BrokerError("kimaru_material_arguments_invalid")
|
|
144
|
+
from .kimaru_material import provision
|
|
145
|
+
return provision(**arguments)
|
|
146
|
+
if operation == "health" and not arguments:
|
|
147
|
+
return {"status": "ready", "pid": os.getpid(), "python": sys.executable,
|
|
148
|
+
"python_realpath": str(Path(sys.executable).resolve()),
|
|
149
|
+
"protocol": 1, "direct_banto_access_blocked": (runtime_dir() / "required").exists()}
|
|
150
|
+
if operation == "api_request":
|
|
151
|
+
return api_request(**arguments)
|
|
152
|
+
if operation in ("register_asc", "register_x_oauth") and not arguments:
|
|
153
|
+
from . import register_popup
|
|
154
|
+
name = "serve_asc_register_popup" if operation == "register_asc" else "serve_x_oauth_register_popup"
|
|
155
|
+
return {"url": getattr(register_popup, name)(blocking=False), "status": "popup_opened"}
|
|
156
|
+
if operation not in MCP_OPERATIONS:
|
|
157
|
+
raise BrokerError("operation_not_allowed")
|
|
158
|
+
from . import mcp_server
|
|
159
|
+
function = getattr(mcp_server, operation)
|
|
160
|
+
inspect.signature(function).bind(**arguments)
|
|
161
|
+
return asyncio.run(function(**arguments))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def dispatch(operation: str, arguments: dict) -> dict:
|
|
165
|
+
values: set[str] = set()
|
|
166
|
+
token = _observed.set(values)
|
|
167
|
+
try:
|
|
168
|
+
return redact(_dispatch(operation, arguments), values)
|
|
169
|
+
finally:
|
|
170
|
+
_observed.reset(token)
|
|
171
|
+
values.clear()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def peer_uid(connection) -> int:
|
|
175
|
+
if sys.platform == "darwin":
|
|
176
|
+
# LOCAL_PEERCRED (SOL_LOCAL=0), struct xucred: version, uid, groups.
|
|
177
|
+
raw = connection.getsockopt(0, 1, 80)
|
|
178
|
+
version, uid = struct.unpack_from("=II", raw)
|
|
179
|
+
if version != 0:
|
|
180
|
+
raise BrokerError("unsupported_peer_credentials")
|
|
181
|
+
return uid
|
|
182
|
+
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
|
|
183
|
+
return struct.unpack("3i", raw)[1]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class Handler(socketserver.StreamRequestHandler):
|
|
187
|
+
def handle(self):
|
|
188
|
+
self.connection.settimeout(180)
|
|
189
|
+
try:
|
|
190
|
+
if peer_uid(self.connection) != os.getuid():
|
|
191
|
+
raise BrokerError("peer_denied")
|
|
192
|
+
request = read_message(self.rfile)
|
|
193
|
+
if set(request) != {"operation", "arguments"} or not isinstance(request["arguments"], dict):
|
|
194
|
+
raise BrokerError("invalid_request")
|
|
195
|
+
# Serialized execution keeps existing sync/rotation state safe.
|
|
196
|
+
result = self.server.dispatch(request["operation"], request["arguments"])
|
|
197
|
+
response = {"ok": True, "result": result}
|
|
198
|
+
except BrokerError as error:
|
|
199
|
+
response = {"ok": False, "error": str(error)}
|
|
200
|
+
except Exception:
|
|
201
|
+
# Exceptions can contain credentials or upstream response bodies.
|
|
202
|
+
response = {"ok": False, "error": "operation_failed"}
|
|
203
|
+
try:
|
|
204
|
+
self.wfile.write(encode_message(response))
|
|
205
|
+
except (OSError, BrokerError):
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class Server(socketserver.UnixStreamServer):
|
|
210
|
+
dispatch = staticmethod(dispatch)
|
|
211
|
+
|
|
212
|
+
def handle_error(self, request, client_address):
|
|
213
|
+
pass # Never emit request data or exception tracebacks.
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def serve(path: Path | None = None) -> None:
|
|
217
|
+
global IN_SERVICE
|
|
218
|
+
path = path or socket_path()
|
|
219
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
220
|
+
check_private(path.parent, directory=True)
|
|
221
|
+
lock_fd = os.open(path.parent / "service.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
222
|
+
try:
|
|
223
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
224
|
+
if path.exists() or path.is_symlink():
|
|
225
|
+
check_private(path)
|
|
226
|
+
path.unlink()
|
|
227
|
+
IN_SERVICE = True
|
|
228
|
+
old_umask = os.umask(0o077)
|
|
229
|
+
try:
|
|
230
|
+
server = Server(str(path), Handler)
|
|
231
|
+
finally:
|
|
232
|
+
os.umask(old_umask)
|
|
233
|
+
with server:
|
|
234
|
+
try:
|
|
235
|
+
server.serve_forever()
|
|
236
|
+
finally:
|
|
237
|
+
path.unlink(missing_ok=True)
|
|
238
|
+
finally:
|
|
239
|
+
IN_SERVICE = False
|
|
240
|
+
os.close(lock_fd)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def install() -> None:
|
|
244
|
+
"""Install a user LaunchAgent with one absolute Python path; no Keychain read."""
|
|
245
|
+
if sys.platform != "darwin":
|
|
246
|
+
raise BrokerError("macos_required")
|
|
247
|
+
# Validate dependencies without importing/reading user credentials.
|
|
248
|
+
import mcp # noqa: F401
|
|
249
|
+
directory = runtime_dir()
|
|
250
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
251
|
+
check_private(directory, directory=True)
|
|
252
|
+
plist_path = Path.home() / "Library" / "LaunchAgents" / (LABEL + ".plist")
|
|
253
|
+
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
if plist_path.exists():
|
|
255
|
+
raise BrokerError("launch_agent_already_exists; inspect before replacing")
|
|
256
|
+
spec = {"Label": LABEL, "ProgramArguments": [sys.executable, "-m", "banto.broker", "serve"],
|
|
257
|
+
"WorkingDirectory": str(Path(__file__).resolve().parent.parent),
|
|
258
|
+
"RunAtLoad": True, "KeepAlive": True, "Umask": 0o077,
|
|
259
|
+
"StandardOutPath": "/dev/null", "StandardErrorPath": "/dev/null"}
|
|
260
|
+
with plist_path.open("xb") as target:
|
|
261
|
+
plistlib.dump(spec, target)
|
|
262
|
+
os.chmod(plist_path, 0o600)
|
|
263
|
+
result = subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(plist_path)], capture_output=True)
|
|
264
|
+
if result.returncode:
|
|
265
|
+
plist_path.unlink()
|
|
266
|
+
raise BrokerError("launch_agent_bootstrap_failed")
|
|
267
|
+
result = subprocess.run(["launchctl", "kickstart", f"gui/{os.getuid()}/{LABEL}"], capture_output=True)
|
|
268
|
+
if result.returncode:
|
|
269
|
+
raise BrokerError("launch_agent_installed_but_start_failed")
|
|
270
|
+
deadline = time.monotonic() + 10
|
|
271
|
+
while True:
|
|
272
|
+
try:
|
|
273
|
+
BrokerClient(timeout=0.5).call("health")
|
|
274
|
+
break
|
|
275
|
+
except BrokerError:
|
|
276
|
+
if time.monotonic() >= deadline:
|
|
277
|
+
raise BrokerError("launch_agent_installed_but_not_ready") from None
|
|
278
|
+
time.sleep(0.1)
|
|
279
|
+
print(json.dumps({"installed": True, "plist": str(plist_path), "python": sys.executable}))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def main() -> None:
|
|
283
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
284
|
+
parser.add_argument("command", choices=["serve", "install", "health", "enable", "disable", "call"])
|
|
285
|
+
parser.add_argument("operation", nargs="?")
|
|
286
|
+
args = parser.parse_args()
|
|
287
|
+
try:
|
|
288
|
+
if args.command == "serve":
|
|
289
|
+
# -m executes this file as __main__; use the canonical module so
|
|
290
|
+
# Keychain's guard sees the same IN_SERVICE flag.
|
|
291
|
+
from . import broker
|
|
292
|
+
broker.serve()
|
|
293
|
+
elif args.command == "install":
|
|
294
|
+
install()
|
|
295
|
+
elif args.command == "enable":
|
|
296
|
+
BrokerClient().call("health")
|
|
297
|
+
(runtime_dir() / "required").touch(mode=0o600)
|
|
298
|
+
print('{"direct_banto_access_blocked": true}')
|
|
299
|
+
elif args.command == "disable":
|
|
300
|
+
(runtime_dir() / "required").unlink(missing_ok=True)
|
|
301
|
+
print('{"direct_banto_access_blocked": false, "restart_mcp_clients": true}')
|
|
302
|
+
else:
|
|
303
|
+
operation = "health" if args.command == "health" else args.operation
|
|
304
|
+
if not operation:
|
|
305
|
+
parser.error("call requires an operation; arguments are a JSON object on stdin")
|
|
306
|
+
arguments = {} if args.command == "health" else json.load(sys.stdin)
|
|
307
|
+
print(json.dumps(BrokerClient().call(operation, **arguments), ensure_ascii=False))
|
|
308
|
+
except (BrokerError, OSError, ValueError, TypeError):
|
|
309
|
+
print("Broker command failed; no direct Keychain fallback. Check service and operation arguments.", file=sys.stderr)
|
|
310
|
+
sys.exit(1)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
if __name__ == "__main__":
|
|
314
|
+
main()
|
banto/broker_cli.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Route supported legacy CLI commands through the installed broker."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from .broker_client import BrokerClient, BrokerError, runtime_dir
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def route_if_enabled(args: list[str]) -> bool:
|
|
8
|
+
if not (runtime_dir() / "required").exists():
|
|
9
|
+
return False
|
|
10
|
+
operation = None
|
|
11
|
+
arguments = {}
|
|
12
|
+
if args[0] == "register" and len(args) <= 2:
|
|
13
|
+
operation = "banto_register_key"
|
|
14
|
+
arguments = {"provider": args[1] if len(args) == 2 else ""}
|
|
15
|
+
elif args in (["register-asc"], ["asc-register"]):
|
|
16
|
+
operation = "register_asc"
|
|
17
|
+
elif args == ["register-x-oauth"]:
|
|
18
|
+
operation = "register_x_oauth"
|
|
19
|
+
elif args[0] == "sync":
|
|
20
|
+
if args[1:] == ["status"]:
|
|
21
|
+
operation = "banto_sync_status"
|
|
22
|
+
elif args[1:] == ["audit"]:
|
|
23
|
+
operation = "banto_sync_audit"
|
|
24
|
+
elif args[1:] == ["validate"]:
|
|
25
|
+
operation = "banto_validate"
|
|
26
|
+
elif args[1:] == ["validate", "--keychain"]:
|
|
27
|
+
operation = "banto_validate_keychain"
|
|
28
|
+
elif args[1:] == ["push"]:
|
|
29
|
+
operation = "banto_sync_push"
|
|
30
|
+
if operation:
|
|
31
|
+
try:
|
|
32
|
+
print(json.dumps(BrokerClient().call(operation, **arguments), ensure_ascii=False))
|
|
33
|
+
except BrokerError:
|
|
34
|
+
raise SystemExit("Broker operation failed; no direct Keychain fallback. Check service status.") from None
|
|
35
|
+
return True
|
|
36
|
+
# Existing metadata and Secure Enclave commands keep their behavior. Other
|
|
37
|
+
# legacy commands need explicit migration, rather than a silent bypass.
|
|
38
|
+
if args[0] in {"store", "delete", "sync", "lease"}:
|
|
39
|
+
raise SystemExit("This legacy command is disabled in broker mode. Use banto register or an allowlisted broker operation.")
|
|
40
|
+
return False
|
banto/broker_client.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Local operation client. This module never imports or reads Keychain."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import socket
|
|
8
|
+
import stat
|
|
9
|
+
|
|
10
|
+
MAX_MESSAGE = 8 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BrokerError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def runtime_dir() -> Path:
|
|
18
|
+
return Path.home() / "Library" / "Application Support" / "banto" / "broker"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def socket_path() -> Path:
|
|
22
|
+
return runtime_dir() / "broker.sock"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_message(stream) -> dict:
|
|
26
|
+
line = stream.readline(MAX_MESSAGE + 1)
|
|
27
|
+
if len(line) > MAX_MESSAGE or not line.endswith(b"\n"):
|
|
28
|
+
raise BrokerError("invalid_message")
|
|
29
|
+
value = json.loads(line)
|
|
30
|
+
if not isinstance(value, dict):
|
|
31
|
+
raise BrokerError("invalid_message")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def encode_message(value: dict) -> bytes:
|
|
36
|
+
data = json.dumps(value, ensure_ascii=True, allow_nan=False).encode() + b"\n"
|
|
37
|
+
if len(data) > MAX_MESSAGE:
|
|
38
|
+
raise BrokerError("message_too_large")
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_private(path: Path, *, directory: bool = False) -> None:
|
|
43
|
+
info = path.lstat()
|
|
44
|
+
expected = stat.S_ISDIR if directory else stat.S_ISSOCK
|
|
45
|
+
if not expected(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
|
46
|
+
raise BrokerError("unsafe_broker_path")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BrokerClient:
|
|
50
|
+
def __init__(self, path: Path | None = None, *, timeout: float = 180):
|
|
51
|
+
self.path = path or socket_path()
|
|
52
|
+
self.timeout = timeout
|
|
53
|
+
|
|
54
|
+
def call(self, operation: str, **arguments) -> dict:
|
|
55
|
+
"""Invoke an allowlisted operation. No automatic retry or direct fallback."""
|
|
56
|
+
try:
|
|
57
|
+
check_private(self.path.parent, directory=True)
|
|
58
|
+
check_private(self.path)
|
|
59
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
60
|
+
connection.settimeout(self.timeout)
|
|
61
|
+
connection.connect(str(self.path))
|
|
62
|
+
connection.sendall(encode_message({"operation": operation, "arguments": arguments}))
|
|
63
|
+
with connection.makefile("rb") as stream:
|
|
64
|
+
result = read_message(stream)
|
|
65
|
+
except (OSError, ValueError):
|
|
66
|
+
raise BrokerError("broker_unavailable_or_outcome_unknown; do not retry mutations blindly") from None
|
|
67
|
+
if not result.get("ok"):
|
|
68
|
+
raise BrokerError(result.get("error", "operation_failed"))
|
|
69
|
+
return result["result"]
|
banto/broker_mcp.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""MCP transport bridge. All operations execute in the common local broker."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import functools
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from mcp.server.fastmcp import FastMCP
|
|
9
|
+
|
|
10
|
+
from .broker import MCP_OPERATIONS
|
|
11
|
+
from .broker_client import BrokerClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def bridge(function):
|
|
15
|
+
@functools.wraps(function)
|
|
16
|
+
async def forwarded(*args, **kwargs):
|
|
17
|
+
import inspect
|
|
18
|
+
arguments = inspect.signature(function).bind(*args, **kwargs)
|
|
19
|
+
arguments.apply_defaults()
|
|
20
|
+
return await asyncio.to_thread(BrokerClient().call, function.__name__, **arguments.arguments)
|
|
21
|
+
return forwarded
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_mcp() -> FastMCP:
|
|
25
|
+
from . import mcp_server
|
|
26
|
+
server = FastMCP("banto")
|
|
27
|
+
for name in MCP_OPERATIONS:
|
|
28
|
+
# Preserve each tool's existing annotations and schema.
|
|
29
|
+
original = mcp_server.mcp._tool_manager.get_tool(name)
|
|
30
|
+
server.add_tool(bridge(getattr(mcp_server, name)), annotations=original.annotations)
|
|
31
|
+
|
|
32
|
+
@server.tool(annotations={"readOnlyHint": False, "destructiveHint": False, "openWorldHint": True})
|
|
33
|
+
async def banto_api_request(provider: str, payload: dict) -> dict:
|
|
34
|
+
"""Call OpenAI Responses or Anthropic Messages without exposing the key.
|
|
35
|
+
|
|
36
|
+
This incurs provider API usage. Supply the model explicitly. No arbitrary
|
|
37
|
+
URLs, auth headers, streaming, remote tools, or secret-return operations.
|
|
38
|
+
"""
|
|
39
|
+
return await asyncio.to_thread(BrokerClient().call, "api_request", provider=provider, payload=payload)
|
|
40
|
+
|
|
41
|
+
@server.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
|
|
42
|
+
async def banto_broker_health() -> dict:
|
|
43
|
+
"""Check the common process without reading secrets or contacting providers."""
|
|
44
|
+
return await asyncio.to_thread(BrokerClient().call, "health")
|
|
45
|
+
|
|
46
|
+
return server
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> None:
|
|
50
|
+
# Only a local stdio bridge is exposed here. Remote HTTP ingress needs its
|
|
51
|
+
# own authenticated gateway, not an unauthenticated loopback MCP endpoint.
|
|
52
|
+
if sys.argv[1:] not in ([], ["--transport", "stdio"]):
|
|
53
|
+
raise SystemExit("Broker MCP supports local stdio only. Use a separately authenticated gateway for remote clients.")
|
|
54
|
+
build_mcp().run(transport="stdio")
|
banto/broker_setup.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Configure agent defaults without exposing existing config contents.
|
|
2
|
+
|
|
3
|
+
Only the banto entry and an explicit managed instruction block are changed.
|
|
4
|
+
Other tools, credentials, models, approval settings and reasoning are preserved.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import tempfile
|
|
13
|
+
|
|
14
|
+
START = "<!-- BEGIN banto-broker-default -->"
|
|
15
|
+
END = "<!-- END banto-broker-default -->"
|
|
16
|
+
RULE = """## Keychain利用の既定経路
|
|
17
|
+
|
|
18
|
+
Keychainに登録済みの秘密値を利用・登録する場合は、共通bantoサービスを既定とする。
|
|
19
|
+
- MCPの `banto` ツール、または `banto.broker_client.BrokerClient` から操作を依頼する。
|
|
20
|
+
- 秘密値そのものを返す操作、`get_key()` / `KeychainStore.get()`、`security ... -w`、
|
|
21
|
+
Security.frameworkの直接呼び出し、環境変数への秘密値の取り出しを使用しない。
|
|
22
|
+
- 登録は `banto_register_key` の本人用ローカル画面を使う。チャットに秘密値を入力させない。
|
|
23
|
+
- 未対応の操作はbantoの許可された操作として実装・検証してから利用する。
|
|
24
|
+
banto停止・未対応・認証エラー時に直接読み取りへ迂回しない。
|
|
25
|
+
- API送信・課金・外部変更は元の承認範囲と予算を守る。登録されたキーの一覧・値を一括取得しない。
|
|
26
|
+
- macOSのパスワード、Touch ID、MFA等の本人確認はユーザーが操作する。
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def update_rules(text: str) -> str:
|
|
31
|
+
block = START + "\n" + RULE + END
|
|
32
|
+
if START in text or END in text:
|
|
33
|
+
if text.count(START) != 1 or text.count(END) != 1 or text.index(START) > text.index(END):
|
|
34
|
+
raise ValueError("invalid managed instruction block")
|
|
35
|
+
return text[:text.index(START)] + block + text[text.index(END) + len(END):]
|
|
36
|
+
return text.rstrip() + "\n\n" + block + "\n"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def json_update(text: str, entry: dict) -> str:
|
|
40
|
+
data = json.loads(text) if text.strip() else {}
|
|
41
|
+
if not isinstance(data, dict) or not isinstance(data.get("mcpServers", {}), dict):
|
|
42
|
+
raise ValueError("invalid MCP configuration")
|
|
43
|
+
data.setdefault("mcpServers", {})["banto"] = entry
|
|
44
|
+
return json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def toml_update(text: str, command: str) -> str:
|
|
48
|
+
import tomllib
|
|
49
|
+
parsed = tomllib.loads(text)
|
|
50
|
+
entry = {"command": command, "args": [], "enabled": True}
|
|
51
|
+
existing = parsed.get("mcp_servers", {}).get("banto")
|
|
52
|
+
if existing == entry:
|
|
53
|
+
return text
|
|
54
|
+
if existing is not None:
|
|
55
|
+
# Never discard auth headers or nested tables from an existing entry.
|
|
56
|
+
raise ValueError("existing Codex banto entry differs; review it before replacing")
|
|
57
|
+
block = '\n[mcp_servers.banto]\ncommand = ' + json.dumps(command) + '\nargs = []\nenabled = true\n'
|
|
58
|
+
result = text.rstrip() + "\n" + block
|
|
59
|
+
assert tomllib.loads(result)["mcp_servers"]["banto"] == entry
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def plan(home: Path, workspace: Path, command: Path, *, claude_config_dir: Path | None = None) -> dict[Path, str]:
|
|
64
|
+
if not command.is_absolute():
|
|
65
|
+
raise ValueError("launcher must be absolute")
|
|
66
|
+
entry = {"command": str(command), "args": []}
|
|
67
|
+
changes = {}
|
|
68
|
+
json_paths = [workspace / "mcp/.mcp.json", workspace / ".gemini/settings.json",
|
|
69
|
+
workspace / "mcp_config.json", home / ".claude.json",
|
|
70
|
+
home / ".gemini/settings.json", home / ".gemini/antigravity/mcp_config.json"]
|
|
71
|
+
if claude_config_dir is not None:
|
|
72
|
+
json_paths.append(claude_config_dir / ".claude.json")
|
|
73
|
+
for path in json_paths:
|
|
74
|
+
old = path.read_text() if path.exists() else ""
|
|
75
|
+
# Work on the resolved path so symlinked configs retain their aliases.
|
|
76
|
+
changes[path.resolve()] = json_update(old, entry)
|
|
77
|
+
path = home / ".codex/config.toml"
|
|
78
|
+
changes[path.resolve()] = toml_update(path.read_text() if path.exists() else "", str(command))
|
|
79
|
+
rule_paths = [home / rel for rel in (".codex/AGENTS.md", ".claude/CLAUDE.md", ".gemini/GEMINI.md")]
|
|
80
|
+
if claude_config_dir is not None:
|
|
81
|
+
rule_paths.append(claude_config_dir / "CLAUDE.md")
|
|
82
|
+
for path in rule_paths:
|
|
83
|
+
changes[path.resolve()] = update_rules(path.read_text() if path.exists() else "")
|
|
84
|
+
return {path: text for path, text in changes.items() if not path.exists() or path.read_text() != text}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def apply(changes: dict[Path, str]) -> None:
|
|
88
|
+
for path, text in changes.items():
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
mode = path.stat().st_mode & 0o777 if path.exists() else 0o600
|
|
91
|
+
fd, name = tempfile.mkstemp(prefix=".banto-config-", dir=path.parent)
|
|
92
|
+
try:
|
|
93
|
+
with os.fdopen(fd, "w") as stream:
|
|
94
|
+
stream.write(text)
|
|
95
|
+
os.chmod(name, mode)
|
|
96
|
+
os.replace(name, path)
|
|
97
|
+
finally:
|
|
98
|
+
Path(name).unlink(missing_ok=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main() -> None:
|
|
102
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
103
|
+
parser.add_argument("--workspace", type=Path, required=True)
|
|
104
|
+
parser.add_argument("--launcher", type=Path, required=True)
|
|
105
|
+
parser.add_argument("--apply", action="store_true")
|
|
106
|
+
args = parser.parse_args()
|
|
107
|
+
try:
|
|
108
|
+
override = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
109
|
+
claude_dir = Path(override).expanduser().resolve() if override else None
|
|
110
|
+
changes = plan(Path.home(), args.workspace, args.launcher, claude_config_dir=claude_dir)
|
|
111
|
+
if args.apply:
|
|
112
|
+
if not args.launcher.is_file() or not os.access(args.launcher, os.X_OK):
|
|
113
|
+
raise ValueError("launcher not executable")
|
|
114
|
+
apply(changes)
|
|
115
|
+
print(json.dumps({"applied": args.apply, "paths": [str(p) for p in changes]}, ensure_ascii=False))
|
|
116
|
+
except (ValueError, OSError):
|
|
117
|
+
raise SystemExit("Default configuration failed; config values suppressed. Review the target structure.") from None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
main()
|