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.
Files changed (98) hide show
  1. banto/__init__.py +33 -0
  2. banto/__main__.py +560 -0
  3. banto/backend.py +45 -0
  4. banto/broker.py +314 -0
  5. banto/broker_cli.py +40 -0
  6. banto/broker_client.py +69 -0
  7. banto/broker_mcp.py +54 -0
  8. banto/broker_setup.py +121 -0
  9. banto/chatgpt.py +186 -0
  10. banto/config.json +43 -0
  11. banto/ecdsa_p256.py +127 -0
  12. banto/guard.py +887 -0
  13. banto/keychain.py +260 -0
  14. banto/kimaru_evaluation.py +366 -0
  15. banto/kimaru_material.py +278 -0
  16. banto/lease.py +292 -0
  17. banto/lease_cli.py +150 -0
  18. banto/mcp_server.py +764 -0
  19. banto/pricing.json +75 -0
  20. banto/profiles.py +147 -0
  21. banto/register_popup.py +1383 -0
  22. banto/rotation_capabilities.json +361 -0
  23. banto/sealed_cli.py +70 -0
  24. banto/sealed_signer.py +468 -0
  25. banto/sync/__init__.py +13 -0
  26. banto/sync/audit.py +46 -0
  27. banto/sync/browser_batch.py +320 -0
  28. banto/sync/browser_issuer.py +831 -0
  29. banto/sync/browser_recorder.py +472 -0
  30. banto/sync/capabilities.py +173 -0
  31. banto/sync/cli.py +4076 -0
  32. banto/sync/cloudflare_token_rotator.py +397 -0
  33. banto/sync/config.py +247 -0
  34. banto/sync/drivers/__init__.py +97 -0
  35. banto/sync/drivers/alibaba.py +114 -0
  36. banto/sync/drivers/aws_sm.py +113 -0
  37. banto/sync/drivers/aws_ssm.py +96 -0
  38. banto/sync/drivers/azure.py +81 -0
  39. banto/sync/drivers/azure_devops.py +100 -0
  40. banto/sync/drivers/base.py +29 -0
  41. banto/sync/drivers/bitbucket.py +132 -0
  42. banto/sync/drivers/circleci.py +99 -0
  43. banto/sync/drivers/cloudflare.py +61 -0
  44. banto/sync/drivers/deno.py +73 -0
  45. banto/sync/drivers/digitalocean.py +75 -0
  46. banto/sync/drivers/docker.py +66 -0
  47. banto/sync/drivers/flyio.py +66 -0
  48. banto/sync/drivers/gcp.py +81 -0
  49. banto/sync/drivers/github.py +57 -0
  50. banto/sync/drivers/gitlab.py +84 -0
  51. banto/sync/drivers/hasura.py +127 -0
  52. banto/sync/drivers/heroku.py +61 -0
  53. banto/sync/drivers/huawei.py +109 -0
  54. banto/sync/drivers/jdcloud.py +95 -0
  55. banto/sync/drivers/kubernetes.py +105 -0
  56. banto/sync/drivers/laravel_forge.py +141 -0
  57. banto/sync/drivers/local.py +130 -0
  58. banto/sync/drivers/naver.py +106 -0
  59. banto/sync/drivers/netlify.py +68 -0
  60. banto/sync/drivers/nhn.py +122 -0
  61. banto/sync/drivers/railway.py +72 -0
  62. banto/sync/drivers/render.py +172 -0
  63. banto/sync/drivers/sakura.py +117 -0
  64. banto/sync/drivers/supabase.py +76 -0
  65. banto/sync/drivers/tencent.py +109 -0
  66. banto/sync/drivers/terraform.py +137 -0
  67. banto/sync/drivers/vercel.py +164 -0
  68. banto/sync/drivers/volcengine.py +97 -0
  69. banto/sync/google_rotator.py +637 -0
  70. banto/sync/history.py +208 -0
  71. banto/sync/incident_report.py +159 -0
  72. banto/sync/notifiers/__init__.py +34 -0
  73. banto/sync/notifiers/base.py +43 -0
  74. banto/sync/notifiers/datadog.py +66 -0
  75. banto/sync/notifiers/pagerduty.py +73 -0
  76. banto/sync/notifiers/slack.py +57 -0
  77. banto/sync/notifiers/teams.py +59 -0
  78. banto/sync/openai_rotator.py +526 -0
  79. banto/sync/propagation.py +269 -0
  80. banto/sync/setup.py +239 -0
  81. banto/sync/smoke_presets.py +153 -0
  82. banto/sync/stripe_webhook_rotator.py +516 -0
  83. banto/sync/sync.py +277 -0
  84. banto/sync/sync_state.py +116 -0
  85. banto/sync/validate.py +337 -0
  86. banto/sync/vercel_inventory.py +185 -0
  87. banto/sync/web.py +1881 -0
  88. banto/sync/x_oauth_pkce.py +294 -0
  89. banto/sync/x_oauth_rotator.py +284 -0
  90. banto/sync/xai_rotator.py +542 -0
  91. banto/vault.py +364 -0
  92. banto/widgets.py +566 -0
  93. banto-5.2.2.dist-info/METADATA +713 -0
  94. banto-5.2.2.dist-info/RECORD +98 -0
  95. banto-5.2.2.dist-info/WHEEL +5 -0
  96. banto-5.2.2.dist-info/entry_points.txt +3 -0
  97. banto-5.2.2.dist-info/licenses/LICENSE +93 -0
  98. 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()