farai 0.1.2 → 0.1.3
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.
- package/dist/cli/index.js +20395 -14425
- package/dist/cli/index.js.map +87 -60
- package/docker/kali/farai-tool-manifest.json +6 -0
- package/docker/kali/farai_mitmproxy_mcp.py +253 -22
- package/package.json +2 -1
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"hashcat",
|
|
34
34
|
"hydra",
|
|
35
35
|
"impacket-scripts",
|
|
36
|
+
"imagemagick",
|
|
36
37
|
"iproute2",
|
|
37
38
|
"iptables",
|
|
38
39
|
"iputils-ping",
|
|
@@ -56,6 +57,7 @@
|
|
|
56
57
|
"nuclei",
|
|
57
58
|
"openssh-client",
|
|
58
59
|
"openssl",
|
|
60
|
+
"poppler-utils",
|
|
59
61
|
"pipx",
|
|
60
62
|
"procps",
|
|
61
63
|
"python3",
|
|
@@ -75,6 +77,7 @@
|
|
|
75
77
|
"steghide",
|
|
76
78
|
"subfinder",
|
|
77
79
|
"tcpdump",
|
|
80
|
+
"tesseract-ocr",
|
|
78
81
|
"testssl.sh",
|
|
79
82
|
"theharvester",
|
|
80
83
|
"tree",
|
|
@@ -98,14 +101,17 @@
|
|
|
98
101
|
"curl",
|
|
99
102
|
"dig",
|
|
100
103
|
"file",
|
|
104
|
+
"identify",
|
|
101
105
|
"ip",
|
|
102
106
|
"iptables",
|
|
103
107
|
"jq",
|
|
104
108
|
"nc",
|
|
105
109
|
"openssl",
|
|
110
|
+
"pdftotext",
|
|
106
111
|
"ping",
|
|
107
112
|
"rg",
|
|
108
113
|
"ssh",
|
|
114
|
+
"tesseract",
|
|
109
115
|
"unzip",
|
|
110
116
|
"wget",
|
|
111
117
|
"whois",
|
|
@@ -5,8 +5,10 @@ from __future__ import annotations
|
|
|
5
5
|
import base64
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
8
|
+
import re
|
|
8
9
|
import sqlite3
|
|
9
10
|
import sys
|
|
11
|
+
import uuid
|
|
10
12
|
from datetime import datetime, timezone
|
|
11
13
|
from typing import Any
|
|
12
14
|
|
|
@@ -28,15 +30,16 @@ def env_int(name: str, default: int, minimum: int, maximum: int) -> int:
|
|
|
28
30
|
MAX_BODY_PREVIEW = env_int("FARAI_PROXY_BODY_PREVIEW_BYTES", 65536, 1024, 1024 * 1024)
|
|
29
31
|
MAX_MESSAGE_PREVIEW = env_int("FARAI_PROXY_MESSAGE_PREVIEW_BYTES", 16384, 1024, 256 * 1024)
|
|
30
32
|
MAX_MESSAGES = env_int("FARAI_PROXY_MAX_MESSAGES", 200, 1, 1000)
|
|
33
|
+
REPLAY_CORRELATION_HEADER = "X-Farai-Replay-Correlation"
|
|
31
34
|
|
|
32
35
|
|
|
33
|
-
class
|
|
36
|
+
class FlowStore:
|
|
34
37
|
def __init__(self, db_path: str):
|
|
35
38
|
self.db_path = db_path
|
|
36
39
|
with self._connect() as conn:
|
|
37
40
|
conn.execute(
|
|
38
41
|
"""
|
|
39
|
-
CREATE TABLE IF NOT EXISTS
|
|
42
|
+
CREATE TABLE IF NOT EXISTS farai_flows (
|
|
40
43
|
id TEXT PRIMARY KEY,
|
|
41
44
|
kind TEXT NOT NULL,
|
|
42
45
|
timestamp REAL NOT NULL,
|
|
@@ -46,12 +49,12 @@ class FlowStoreV2:
|
|
|
46
49
|
"""
|
|
47
50
|
)
|
|
48
51
|
conn.execute(
|
|
49
|
-
"CREATE INDEX IF NOT EXISTS
|
|
50
|
-
"ON
|
|
52
|
+
"CREATE INDEX IF NOT EXISTS idx_farai_flows_timestamp "
|
|
53
|
+
"ON farai_flows(timestamp)"
|
|
51
54
|
)
|
|
52
55
|
conn.execute(
|
|
53
|
-
"CREATE INDEX IF NOT EXISTS
|
|
54
|
-
"ON
|
|
56
|
+
"CREATE INDEX IF NOT EXISTS idx_farai_flows_kind "
|
|
57
|
+
"ON farai_flows(kind)"
|
|
55
58
|
)
|
|
56
59
|
|
|
57
60
|
def _connect(self) -> sqlite3.Connection:
|
|
@@ -65,7 +68,7 @@ class FlowStoreV2:
|
|
|
65
68
|
with self._connect() as conn:
|
|
66
69
|
conn.execute(
|
|
67
70
|
"""
|
|
68
|
-
INSERT INTO
|
|
71
|
+
INSERT INTO farai_flows(id, kind, timestamp, summary_json, detail_json)
|
|
69
72
|
VALUES (?, ?, ?, ?, ?)
|
|
70
73
|
ON CONFLICT(id) DO UPDATE SET
|
|
71
74
|
kind=excluded.kind,
|
|
@@ -78,7 +81,7 @@ class FlowStoreV2:
|
|
|
78
81
|
|
|
79
82
|
def summaries(self, limit: int, kind: str | None = None) -> list[dict[str, Any]]:
|
|
80
83
|
bounded_limit = max(1, min(int(limit), 1000))
|
|
81
|
-
sql = "SELECT summary_json FROM
|
|
84
|
+
sql = "SELECT summary_json FROM farai_flows"
|
|
82
85
|
params: list[Any] = []
|
|
83
86
|
if kind:
|
|
84
87
|
sql += " WHERE kind = ?"
|
|
@@ -92,22 +95,42 @@ class FlowStoreV2:
|
|
|
92
95
|
def detail(self, flow_id: str) -> dict[str, Any] | None:
|
|
93
96
|
with self._connect() as conn:
|
|
94
97
|
row = conn.execute(
|
|
95
|
-
"SELECT detail_json FROM
|
|
98
|
+
"SELECT detail_json FROM farai_flows WHERE id = ?", (flow_id,)
|
|
96
99
|
).fetchone()
|
|
97
100
|
return json.loads(row[0]) if row else None
|
|
98
101
|
|
|
99
102
|
def clear(self) -> None:
|
|
100
103
|
with self._connect() as conn:
|
|
101
|
-
conn.execute("DELETE FROM
|
|
104
|
+
conn.execute("DELETE FROM farai_flows")
|
|
102
105
|
|
|
103
106
|
|
|
104
107
|
class FaraiTrafficRecorder(UPSTREAM_TRAFFIC_RECORDER):
|
|
105
108
|
def __init__(self, scope: Any):
|
|
106
109
|
super().__init__(scope)
|
|
107
|
-
self.
|
|
110
|
+
self.flows = FlowStore(self.db.db_path)
|
|
111
|
+
self.intercept_config: dict[str, Any] = {
|
|
112
|
+
"enabled": False,
|
|
113
|
+
"host_pattern": ".*",
|
|
114
|
+
"path_pattern": ".*",
|
|
115
|
+
"methods": [],
|
|
116
|
+
}
|
|
117
|
+
self.pending_intercepts: dict[str, http.HTTPFlow] = {}
|
|
118
|
+
self.replay_correlations: dict[str, dict[str, str]] = {}
|
|
108
119
|
|
|
109
120
|
def request(self, flow: http.HTTPFlow) -> None:
|
|
121
|
+
correlation = flow.request.headers.get(REPLAY_CORRELATION_HEADER)
|
|
122
|
+
if correlation:
|
|
123
|
+
flow.request.headers.pop(REPLAY_CORRELATION_HEADER, None)
|
|
124
|
+
replay = self.replay_correlations.get(str(correlation))
|
|
125
|
+
if replay is not None:
|
|
126
|
+
replay["descendantFlowId"] = flow.id
|
|
127
|
+
metadata = getattr(flow, "metadata", None)
|
|
128
|
+
if isinstance(metadata, dict):
|
|
129
|
+
metadata["faraiReplayParentId"] = replay["parentFlowId"]
|
|
110
130
|
super().request(flow)
|
|
131
|
+
if self._should_intercept(flow):
|
|
132
|
+
flow.intercept()
|
|
133
|
+
self.pending_intercepts[flow.id] = flow
|
|
111
134
|
self._capture(flow)
|
|
112
135
|
|
|
113
136
|
def response(self, flow: http.HTTPFlow) -> None:
|
|
@@ -160,24 +183,106 @@ class FaraiTrafficRecorder(UPSTREAM_TRAFFIC_RECORDER):
|
|
|
160
183
|
def dns_error(self, flow: dns.DNSFlow) -> None:
|
|
161
184
|
self._capture(flow)
|
|
162
185
|
|
|
163
|
-
def
|
|
164
|
-
return self.
|
|
186
|
+
def farai_flow_summaries(self, limit: int = 20, kind: str | None = None) -> list[dict[str, Any]]:
|
|
187
|
+
return self.flows.summaries(limit, kind)
|
|
165
188
|
|
|
166
|
-
def
|
|
167
|
-
return self.
|
|
189
|
+
def farai_flow_detail(self, flow_id: str) -> dict[str, Any] | None:
|
|
190
|
+
return self.flows.detail(flow_id)
|
|
168
191
|
|
|
169
192
|
def clear(self) -> None:
|
|
170
193
|
super().clear()
|
|
171
|
-
self.
|
|
194
|
+
self.flows.clear()
|
|
195
|
+
|
|
196
|
+
def scope_state(self) -> dict[str, Any]:
|
|
197
|
+
return {"allowedDomains": list(self.scope.config.allowed_domains)}
|
|
198
|
+
|
|
199
|
+
def configure_intercept(
|
|
200
|
+
self,
|
|
201
|
+
enabled: bool,
|
|
202
|
+
host_pattern: str = ".*",
|
|
203
|
+
path_pattern: str = ".*",
|
|
204
|
+
methods: list[str] | None = None,
|
|
205
|
+
) -> dict[str, Any]:
|
|
206
|
+
re.compile(host_pattern)
|
|
207
|
+
re.compile(path_pattern)
|
|
208
|
+
self.intercept_config = {
|
|
209
|
+
"enabled": bool(enabled),
|
|
210
|
+
"host_pattern": host_pattern,
|
|
211
|
+
"path_pattern": path_pattern,
|
|
212
|
+
"methods": sorted({method.upper() for method in (methods or [])}),
|
|
213
|
+
}
|
|
214
|
+
return {**self.intercept_config, "pending": len(self.pending_intercepts)}
|
|
215
|
+
|
|
216
|
+
def list_intercepts(self) -> list[dict[str, Any]]:
|
|
217
|
+
result: list[dict[str, Any]] = []
|
|
218
|
+
for flow in self.pending_intercepts.values():
|
|
219
|
+
summary, _, _ = serialize_http(flow)
|
|
220
|
+
result.append(summary)
|
|
221
|
+
return sorted(result, key=lambda item: str(item.get("timestamp", "")))
|
|
222
|
+
|
|
223
|
+
def begin_replay(self, parent_flow_id: str) -> tuple[str, dict[str, str]]:
|
|
224
|
+
token = uuid.uuid4().hex
|
|
225
|
+
state = {"parentFlowId": parent_flow_id}
|
|
226
|
+
self.replay_correlations[token] = state
|
|
227
|
+
return token, state
|
|
228
|
+
|
|
229
|
+
def finish_replay(self, token: str) -> dict[str, str]:
|
|
230
|
+
return self.replay_correlations.pop(token, {})
|
|
231
|
+
|
|
232
|
+
def resolve_intercept(
|
|
233
|
+
self,
|
|
234
|
+
flow_id: str,
|
|
235
|
+
action: str,
|
|
236
|
+
method: str | None = None,
|
|
237
|
+
url: str | None = None,
|
|
238
|
+
headers: dict[str, str] | None = None,
|
|
239
|
+
body: str | None = None,
|
|
240
|
+
) -> dict[str, Any]:
|
|
241
|
+
flow = self.pending_intercepts.pop(flow_id, None)
|
|
242
|
+
if flow is None:
|
|
243
|
+
raise ValueError(f"Unknown pending intercepted flow: {flow_id}")
|
|
244
|
+
if action == "drop":
|
|
245
|
+
flow.kill()
|
|
246
|
+
return {"flowId": flow_id, "action": "drop"}
|
|
247
|
+
if action not in {"forward", "edit"}:
|
|
248
|
+
self.pending_intercepts[flow_id] = flow
|
|
249
|
+
raise ValueError("action must be forward, edit, or drop")
|
|
250
|
+
if action == "edit":
|
|
251
|
+
if method:
|
|
252
|
+
flow.request.method = method.upper()
|
|
253
|
+
if url:
|
|
254
|
+
flow.request.url = url
|
|
255
|
+
for key, value in (headers or {}).items():
|
|
256
|
+
flow.request.headers[key] = value
|
|
257
|
+
if body is not None:
|
|
258
|
+
flow.request.text = body
|
|
259
|
+
flow.resume()
|
|
260
|
+
return {"flowId": flow_id, "action": action}
|
|
261
|
+
|
|
262
|
+
def _should_intercept(self, flow: http.HTTPFlow) -> bool:
|
|
263
|
+
config = self.intercept_config
|
|
264
|
+
if not config["enabled"] or flow.id in self.pending_intercepts:
|
|
265
|
+
return False
|
|
266
|
+
methods = config["methods"]
|
|
267
|
+
return (
|
|
268
|
+
(not methods or flow.request.method.upper() in methods)
|
|
269
|
+
and re.search(config["host_pattern"], http_display_host(flow.request)) is not None
|
|
270
|
+
and re.search(config["path_pattern"], flow.request.path) is not None
|
|
271
|
+
)
|
|
172
272
|
|
|
173
273
|
def _capture(self, flow: Any) -> None:
|
|
174
274
|
try:
|
|
175
275
|
if not self._is_allowed(flow):
|
|
176
276
|
return
|
|
177
277
|
summary, detail, timestamp = serialize_flow(flow)
|
|
178
|
-
|
|
278
|
+
metadata = getattr(flow, "metadata", None)
|
|
279
|
+
parent_flow_id = metadata.get("faraiReplayParentId") if isinstance(metadata, dict) else None
|
|
280
|
+
if parent_flow_id:
|
|
281
|
+
summary["parentFlowId"] = parent_flow_id
|
|
282
|
+
detail["parentFlowId"] = parent_flow_id
|
|
283
|
+
self.flows.save(summary, detail, timestamp)
|
|
179
284
|
except Exception as exc:
|
|
180
|
-
print(f"Failed to save Farai
|
|
285
|
+
print(f"Failed to save Farai flow: {exc}", file=sys.stderr)
|
|
181
286
|
|
|
182
287
|
def _is_allowed(self, flow: Any) -> bool:
|
|
183
288
|
if isinstance(flow, http.HTTPFlow):
|
|
@@ -537,24 +642,150 @@ def optional(target: dict[str, Any], key: str, value: Any) -> None:
|
|
|
537
642
|
|
|
538
643
|
|
|
539
644
|
@server.mcp.tool()
|
|
540
|
-
async def
|
|
645
|
+
async def proxy_flow_summaries(limit: int = 20, kind: str | None = None) -> str:
|
|
541
646
|
"""Return HTTP, WebSocket, TCP, UDP, and DNS flow summaries."""
|
|
542
647
|
recorder = server.controller.recorder
|
|
543
648
|
if not isinstance(recorder, FaraiTrafficRecorder):
|
|
544
649
|
return "[]"
|
|
545
|
-
return json.dumps(recorder.
|
|
650
|
+
return json.dumps(recorder.farai_flow_summaries(limit, kind), indent=2)
|
|
546
651
|
|
|
547
652
|
|
|
548
653
|
@server.mcp.tool()
|
|
549
|
-
async def
|
|
654
|
+
async def proxy_flow_inspect(flow_id: str) -> str:
|
|
550
655
|
"""Return protocol-aware detail for a captured flow."""
|
|
551
656
|
recorder = server.controller.recorder
|
|
552
657
|
if not isinstance(recorder, FaraiTrafficRecorder):
|
|
553
658
|
return "Couldn't find that flow."
|
|
554
|
-
detail = recorder.
|
|
659
|
+
detail = recorder.farai_flow_detail(flow_id)
|
|
555
660
|
return json.dumps(detail, indent=2) if detail else "Couldn't find that flow."
|
|
556
661
|
|
|
557
662
|
|
|
663
|
+
@server.mcp.tool()
|
|
664
|
+
async def proxy_replay_correlated(
|
|
665
|
+
flow_id: str,
|
|
666
|
+
method: str | None = None,
|
|
667
|
+
headers_json: str | None = None,
|
|
668
|
+
body: str | None = None,
|
|
669
|
+
timeout: float = 30.0,
|
|
670
|
+
) -> str:
|
|
671
|
+
"""Replay an HTTP flow and return its exact captured descendant flow id."""
|
|
672
|
+
recorder = server.controller.recorder
|
|
673
|
+
if not isinstance(recorder, FaraiTrafficRecorder):
|
|
674
|
+
return json.dumps({"ok": False, "error": "Farai recorder is unavailable."})
|
|
675
|
+
if recorder.farai_flow_detail(flow_id) is None:
|
|
676
|
+
return json.dumps({"ok": False, "error": f"Couldn't find flow {flow_id}."})
|
|
677
|
+
try:
|
|
678
|
+
headers = json.loads(headers_json) if headers_json else {}
|
|
679
|
+
except json.JSONDecodeError as exc:
|
|
680
|
+
return json.dumps({"ok": False, "error": f"Invalid headers_json: {exc}"})
|
|
681
|
+
if not isinstance(headers, dict) or not all(isinstance(key, str) and isinstance(value, str) for key, value in headers.items()):
|
|
682
|
+
return json.dumps({"ok": False, "error": "headers_json must encode a string-to-string object."})
|
|
683
|
+
|
|
684
|
+
# Upstream treats None as "reuse the captured body"; an empty string omits it.
|
|
685
|
+
resolved_body = "" if body == "__omit__" else body
|
|
686
|
+
variables = getattr(server.controller, "session_variables", {})
|
|
687
|
+
resolver = getattr(server, "_resolve_template", None)
|
|
688
|
+
if variables and callable(resolver):
|
|
689
|
+
headers = json.loads(resolver(json.dumps(headers), variables))
|
|
690
|
+
if resolved_body:
|
|
691
|
+
resolved_body = resolver(resolved_body, variables)
|
|
692
|
+
|
|
693
|
+
token, correlation = recorder.begin_replay(flow_id)
|
|
694
|
+
headers[REPLAY_CORRELATION_HEADER] = token
|
|
695
|
+
try:
|
|
696
|
+
message = await server.controller.replay_request(flow_id, method, headers, resolved_body, timeout)
|
|
697
|
+
result = recorder.finish_replay(token)
|
|
698
|
+
except Exception as exc:
|
|
699
|
+
recorder.finish_replay(token)
|
|
700
|
+
return json.dumps({"ok": False, "parentFlowId": flow_id, "error": str(exc)})
|
|
701
|
+
ok = not any(marker in message.lower() for marker in ("couldn't find", "didn't work", "invalid"))
|
|
702
|
+
return json.dumps(
|
|
703
|
+
{
|
|
704
|
+
"ok": ok,
|
|
705
|
+
**correlation,
|
|
706
|
+
**result,
|
|
707
|
+
"message": message,
|
|
708
|
+
**({} if ok else {"error": message}),
|
|
709
|
+
},
|
|
710
|
+
indent=2,
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
@server.mcp.tool()
|
|
715
|
+
async def proxy_scope_get() -> str:
|
|
716
|
+
"""Return Farai's active mitmproxy capture scope."""
|
|
717
|
+
recorder = server.controller.recorder
|
|
718
|
+
if not isinstance(recorder, FaraiTrafficRecorder):
|
|
719
|
+
return json.dumps({"allowedDomains": []})
|
|
720
|
+
return json.dumps(recorder.scope_state(), indent=2)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@server.mcp.tool()
|
|
724
|
+
async def proxy_intercept_configure(
|
|
725
|
+
enabled: bool,
|
|
726
|
+
host_pattern: str = ".*",
|
|
727
|
+
path_pattern: str = ".*",
|
|
728
|
+
methods: list[str] | None = None,
|
|
729
|
+
) -> str:
|
|
730
|
+
"""Enable or disable Farai's manual request interception queue."""
|
|
731
|
+
recorder = server.controller.recorder
|
|
732
|
+
if not isinstance(recorder, FaraiTrafficRecorder):
|
|
733
|
+
raise RuntimeError("Farai recorder is unavailable.")
|
|
734
|
+
try:
|
|
735
|
+
return json.dumps(
|
|
736
|
+
recorder.configure_intercept(enabled, host_pattern, path_pattern, methods),
|
|
737
|
+
indent=2,
|
|
738
|
+
)
|
|
739
|
+
except re.error as exc:
|
|
740
|
+
raise ValueError(f"Invalid interception pattern: {exc}") from exc
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
@server.mcp.tool()
|
|
744
|
+
async def proxy_intercept_get() -> str:
|
|
745
|
+
"""Return Farai's manual interception configuration and queue size."""
|
|
746
|
+
recorder = server.controller.recorder
|
|
747
|
+
if not isinstance(recorder, FaraiTrafficRecorder):
|
|
748
|
+
return json.dumps({"enabled": False, "pending": 0})
|
|
749
|
+
return json.dumps(
|
|
750
|
+
{**recorder.intercept_config, "pending": len(recorder.pending_intercepts)},
|
|
751
|
+
indent=2,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
@server.mcp.tool()
|
|
756
|
+
async def proxy_intercept_list() -> str:
|
|
757
|
+
"""List requests currently paused in Farai's interception queue."""
|
|
758
|
+
recorder = server.controller.recorder
|
|
759
|
+
if not isinstance(recorder, FaraiTrafficRecorder):
|
|
760
|
+
return "[]"
|
|
761
|
+
return json.dumps(recorder.list_intercepts(), indent=2)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
@server.mcp.tool()
|
|
765
|
+
async def proxy_intercept_resolve(
|
|
766
|
+
flow_id: str,
|
|
767
|
+
action: str,
|
|
768
|
+
method: str | None = None,
|
|
769
|
+
url: str | None = None,
|
|
770
|
+
headers_json: str | None = None,
|
|
771
|
+
body: str | None = None,
|
|
772
|
+
) -> str:
|
|
773
|
+
"""Forward, edit, or drop one request from Farai's interception queue."""
|
|
774
|
+
recorder = server.controller.recorder
|
|
775
|
+
if not isinstance(recorder, FaraiTrafficRecorder):
|
|
776
|
+
raise RuntimeError("Farai recorder is unavailable.")
|
|
777
|
+
headers = json.loads(headers_json) if headers_json else None
|
|
778
|
+
if headers is not None and (
|
|
779
|
+
not isinstance(headers, dict)
|
|
780
|
+
or not all(isinstance(key, str) and isinstance(value, str) for key, value in headers.items())
|
|
781
|
+
):
|
|
782
|
+
raise ValueError("headers_json must encode a string-to-string object.")
|
|
783
|
+
return json.dumps(
|
|
784
|
+
recorder.resolve_intercept(flow_id, action, method, url, headers, body),
|
|
785
|
+
indent=2,
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
|
|
558
789
|
server.TrafficRecorder = FaraiTrafficRecorder
|
|
559
790
|
|
|
560
791
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "farai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Cyber-first freestyle local AI agent",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@opentui/core": "0.5.8",
|
|
63
|
+
"ajv": "8.20.0",
|
|
63
64
|
"bun-pty": "0.4.10",
|
|
64
65
|
"entities": "7.0.1",
|
|
65
66
|
"fuzzysort": "3.1.0",
|