mitsu 1.4.21__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.
- mitsu/__init__.py +3 -0
- mitsu/__main__.py +5 -0
- mitsu/agents/__init__.py +5 -0
- mitsu/agents/runtime.py +470 -0
- mitsu/armory/__init__.py +23 -0
- mitsu/armory/catalog.py +370 -0
- mitsu/assets/pets/chibi-mita.webp +0 -0
- mitsu/assets/pets/coding-cat.webp +0 -0
- mitsu/assets/pets/doro.webp +0 -0
- mitsu/assets/pets/elyndex.webp +0 -0
- mitsu/assets/pets/glitchcat.webp +0 -0
- mitsu/assets/pets/gugugaga.webp +0 -0
- mitsu/assets/pets/kaltsit.webp +0 -0
- mitsu/assets/pets/mitsu.webp +0 -0
- mitsu/assets/pets/nene.webp +0 -0
- mitsu/assets/pets/null-wisp.webp +0 -0
- mitsu/assets/pets/pikachu.webp +0 -0
- mitsu/assets/pets/rin.webp +0 -0
- mitsu/assets/pets/tethys.webp +0 -0
- mitsu/assets/pets/yingyu-aima.webp +0 -0
- mitsu/assets/pets/yuna.webp +0 -0
- mitsu/config/__init__.py +18 -0
- mitsu/config/catalog.py +150 -0
- mitsu/config/storage.py +255 -0
- mitsu/pets/__init__.py +12 -0
- mitsu/pets/catalog.py +121 -0
- mitsu/providers/__init__.py +29 -0
- mitsu/providers/client.py +1344 -0
- mitsu/search/__init__.py +19 -0
- mitsu/search/engines.py +586 -0
- mitsu/search/vulnerabilities.py +311 -0
- mitsu/services/__init__.py +1 -0
- mitsu/services/docker_installer.py +235 -0
- mitsu/services/host_environment.py +148 -0
- mitsu/services/redteam_armory.py +619 -0
- mitsu/services/searxng.py +311 -0
- mitsu/tools/__init__.py +5 -0
- mitsu/tools/agent_host.py +1670 -0
- mitsu/tools/armory_mcp.py +423 -0
- mitsu/tools/artifacts.py +110 -0
- mitsu/tools/custom_mcp.py +478 -0
- mitsu/tools/redaction.py +62 -0
- mitsu/tui/__init__.py +1 -0
- mitsu/tui/app.py +230 -0
- mitsu/tui/branding.py +13 -0
- mitsu/tui/chat_rendering.py +37 -0
- mitsu/tui/dialogs/__init__.py +7 -0
- mitsu/tui/dialogs/configuration.py +61 -0
- mitsu/tui/dialogs/providers.py +305 -0
- mitsu/tui/dialogs/search_engines.py +673 -0
- mitsu/tui/file_references.py +82 -0
- mitsu/tui/images.py +156 -0
- mitsu/tui/menu.py +1967 -0
- mitsu/tui/onboarding.py +656 -0
- mitsu/tui/session.py +6167 -0
- mitsu/tui/theme.tcss +2873 -0
- mitsu/tui/updates.py +31 -0
- mitsu/tui/widgets/__init__.py +5 -0
- mitsu/tui/widgets/city_scene.py +141 -0
- mitsu/tui/widgets/mitsu.py +425 -0
- mitsu/tui/widgets/moon_scene.py +139 -0
- mitsu/workspace/__init__.py +5 -0
- mitsu/workspace/intel.py +933 -0
- mitsu/workspace/surface_map.py +364 -0
- mitsu/workspace/undo.py +138 -0
- mitsu-1.4.21.dist-info/METADATA +653 -0
- mitsu-1.4.21.dist-info/RECORD +71 -0
- mitsu-1.4.21.dist-info/WHEEL +5 -0
- mitsu-1.4.21.dist-info/entry_points.txt +2 -0
- mitsu-1.4.21.dist-info/licenses/LICENSE +21 -0
- mitsu-1.4.21.dist-info/top_level.txt +1 -0
mitsu/__init__.py
ADDED
mitsu/__main__.py
ADDED
mitsu/agents/__init__.py
ADDED
mitsu/agents/runtime.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""Run agent turns and maintain normalized conversation history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import threading
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, AsyncIterator, Awaitable, Callable
|
|
12
|
+
|
|
13
|
+
from mitsu.tools import AgentToolHost
|
|
14
|
+
from mitsu.providers import ProviderRequestError, ProviderTurn, RequestCancellation, request_agent_turn
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class AgentEvent:
|
|
19
|
+
kind: str
|
|
20
|
+
data: dict[str, Any]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
ApprovalHandler = Callable[[str, dict[str, Any]], Awaitable[bool]]
|
|
24
|
+
HistoryHandler = Callable[[], None]
|
|
25
|
+
RequestTurn = Callable[
|
|
26
|
+
[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]], Awaitable[ProviderTurn]
|
|
27
|
+
]
|
|
28
|
+
MAX_TOOL_CALLS_PER_TURN = 48
|
|
29
|
+
MAX_BINARY_GREP_SEARCHES_PER_ARTIFACT = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _tool_name(name: str) -> str:
|
|
33
|
+
return name.rsplit("__", 1)[-1]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _guided_checkpoint_tool(name: str) -> bool:
|
|
37
|
+
local_name = _tool_name(name)
|
|
38
|
+
return name.startswith("mitsu__") and (
|
|
39
|
+
local_name.startswith("intel_")
|
|
40
|
+
or local_name in {"user_dialog", "record_exploit", "remove_exploit", "session_upsert"}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _guided_flow_action(name: str) -> bool:
|
|
45
|
+
local_name = _tool_name(name)
|
|
46
|
+
bookkeeping = {
|
|
47
|
+
"user_dialog",
|
|
48
|
+
"ask_user",
|
|
49
|
+
"request_mode",
|
|
50
|
+
"session_upsert",
|
|
51
|
+
"record_exploit",
|
|
52
|
+
"remove_exploit",
|
|
53
|
+
"intel_get_map",
|
|
54
|
+
"armory_tool_catalog",
|
|
55
|
+
}
|
|
56
|
+
return not (name.startswith("mitsu__") and (local_name in bookkeeping or local_name.startswith("intel_")))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def finish_interrupted_tools(
|
|
60
|
+
history: list[dict[str, Any]],
|
|
61
|
+
result: str = "Interrupted by the operator. This action has no completed result; do not retry without their direction.",
|
|
62
|
+
) -> None:
|
|
63
|
+
pending: dict[str, bool] = {}
|
|
64
|
+
repaired: list[dict[str, Any]] = []
|
|
65
|
+
|
|
66
|
+
def complete_pending() -> None:
|
|
67
|
+
results = []
|
|
68
|
+
for call_id, anthropic in pending.items():
|
|
69
|
+
if anthropic:
|
|
70
|
+
results.append({"type": "tool_result", "tool_use_id": call_id, "content": result, "is_error": True})
|
|
71
|
+
else:
|
|
72
|
+
repaired.append({"role": "tool", "tool_call_id": call_id, "content": result})
|
|
73
|
+
if results:
|
|
74
|
+
if repaired and repaired[-1].get("role") == "user" and isinstance(repaired[-1].get("content"), list):
|
|
75
|
+
repaired[-1]["content"].extend(results)
|
|
76
|
+
else:
|
|
77
|
+
repaired.append({"role": "user", "content": results})
|
|
78
|
+
pending.clear()
|
|
79
|
+
|
|
80
|
+
for message in history:
|
|
81
|
+
content = message.get("content")
|
|
82
|
+
result_blocks = [
|
|
83
|
+
block for block in content
|
|
84
|
+
if isinstance(block, dict) and block.get("type") == "tool_result"
|
|
85
|
+
] if isinstance(content, list) and message.get("role") == "user" else []
|
|
86
|
+
if message.get("role") == "tool":
|
|
87
|
+
pending.pop(message.get("tool_call_id", ""), None)
|
|
88
|
+
elif result_blocks:
|
|
89
|
+
for block in result_blocks:
|
|
90
|
+
pending.pop(block.get("tool_use_id", ""), None)
|
|
91
|
+
else:
|
|
92
|
+
complete_pending()
|
|
93
|
+
repaired.append(message)
|
|
94
|
+
if message.get("role") == "assistant":
|
|
95
|
+
for call in message.get("tool_calls") or []:
|
|
96
|
+
pending[call["id"]] = False
|
|
97
|
+
if isinstance(content, list):
|
|
98
|
+
for block in content:
|
|
99
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
100
|
+
pending[block["id"]] = True
|
|
101
|
+
complete_pending()
|
|
102
|
+
history[:] = repaired
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _validated_access_context(intel: dict[str, Any] | None) -> list[dict[str, str]]:
|
|
106
|
+
if not isinstance(intel, dict):
|
|
107
|
+
return []
|
|
108
|
+
records = intel.get("accounts", [])
|
|
109
|
+
if not isinstance(records, list):
|
|
110
|
+
return []
|
|
111
|
+
access_context = []
|
|
112
|
+
for record in records:
|
|
113
|
+
if not isinstance(record, dict) or str(record.get("status", "")).casefold() != "validated":
|
|
114
|
+
continue
|
|
115
|
+
access = str(record.get("access", "")).strip()
|
|
116
|
+
if not access:
|
|
117
|
+
continue
|
|
118
|
+
access_context.append(
|
|
119
|
+
{
|
|
120
|
+
"name": str(record.get("name", "")),
|
|
121
|
+
"target": str(record.get("target", "")),
|
|
122
|
+
"access": access[:240],
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
return access_context
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _failed_call_key(name: str, arguments: Any) -> str | None:
|
|
129
|
+
local_name = _tool_name(name)
|
|
130
|
+
if local_name not in {"bash", "armory_run"} or not isinstance(arguments, dict):
|
|
131
|
+
return None
|
|
132
|
+
if local_name == "bash":
|
|
133
|
+
command = str(arguments.get("command", "")).strip()
|
|
134
|
+
return json.dumps([local_name, command], ensure_ascii=False, separators=(",", ":"))
|
|
135
|
+
return json.dumps(
|
|
136
|
+
[local_name, arguments.get("tool_id"), arguments.get("argv")],
|
|
137
|
+
ensure_ascii=False,
|
|
138
|
+
sort_keys=True,
|
|
139
|
+
separators=(",", ":"),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _binary_grep_key(name: str, arguments: Any) -> str | None:
|
|
144
|
+
if _tool_name(name) != "bash" or not isinstance(arguments, dict):
|
|
145
|
+
return None
|
|
146
|
+
command = str(arguments.get("command", ""))
|
|
147
|
+
if not re.search(r"\bstrings\b", command) or not re.search(r"\b(?:grep|rg)\b", command):
|
|
148
|
+
return None
|
|
149
|
+
match = re.search(r"\bstrings\b([^|;&]+)", command)
|
|
150
|
+
if match is None:
|
|
151
|
+
return None
|
|
152
|
+
try:
|
|
153
|
+
tokens = shlex.split(match.group(1))
|
|
154
|
+
except ValueError:
|
|
155
|
+
return None
|
|
156
|
+
skip_value = False
|
|
157
|
+
for token in tokens:
|
|
158
|
+
if skip_value:
|
|
159
|
+
skip_value = False
|
|
160
|
+
continue
|
|
161
|
+
if token in {"-n", "--bytes"}:
|
|
162
|
+
skip_value = True
|
|
163
|
+
elif token.startswith("-"):
|
|
164
|
+
continue
|
|
165
|
+
else:
|
|
166
|
+
return f"strings-grep:{token}"
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _request_agent_turn_background(
|
|
171
|
+
profile: dict[str, Any],
|
|
172
|
+
messages: list[dict[str, Any]],
|
|
173
|
+
tools: list[dict[str, Any]],
|
|
174
|
+
on_event: Callable[[str, dict[str, Any]], None] | None = None,
|
|
175
|
+
cancellation: RequestCancellation | None = None,
|
|
176
|
+
) -> asyncio.Future[ProviderTurn]:
|
|
177
|
+
loop = asyncio.get_running_loop()
|
|
178
|
+
result: asyncio.Future[ProviderTurn] = loop.create_future()
|
|
179
|
+
|
|
180
|
+
def finish(turn: ProviderTurn | None, error: Exception | None) -> None:
|
|
181
|
+
if result.done():
|
|
182
|
+
return
|
|
183
|
+
if error is not None:
|
|
184
|
+
result.set_exception(error)
|
|
185
|
+
elif turn is not None:
|
|
186
|
+
result.set_result(turn)
|
|
187
|
+
|
|
188
|
+
def run() -> None:
|
|
189
|
+
try:
|
|
190
|
+
turn = request_agent_turn(profile, messages, tools, on_event, cancellation)
|
|
191
|
+
except Exception as error:
|
|
192
|
+
turn = None
|
|
193
|
+
failure = error
|
|
194
|
+
else:
|
|
195
|
+
failure = None
|
|
196
|
+
try:
|
|
197
|
+
loop.call_soon_threadsafe(finish, turn, failure)
|
|
198
|
+
except RuntimeError:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
threading.Thread(target=run, name="mitsu-provider-request", daemon=True).start()
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def run_agent_turn(
|
|
206
|
+
profile: dict[str, Any],
|
|
207
|
+
system_prompt: str,
|
|
208
|
+
history: list[dict[str, Any]],
|
|
209
|
+
prompt: str,
|
|
210
|
+
tool_host: AgentToolHost,
|
|
211
|
+
mode: str,
|
|
212
|
+
approve: ApprovalHandler,
|
|
213
|
+
on_history: HistoryHandler,
|
|
214
|
+
request_turn: RequestTurn | None = None,
|
|
215
|
+
get_mode: Callable[[], str] | None = None,
|
|
216
|
+
get_system_prompt: Callable[[], str] | None = None,
|
|
217
|
+
image_paths: list[str] | None = None,
|
|
218
|
+
pause_after_response: bool = False,
|
|
219
|
+
interrupt_requested: Callable[[], bool] | None = None,
|
|
220
|
+
operator_messages: list[str] | None = None,
|
|
221
|
+
get_session_intel: Callable[[], dict[str, Any]] | None = None,
|
|
222
|
+
) -> AsyncIterator[AgentEvent]:
|
|
223
|
+
anthropic = profile.get("protocol") == "anthropic"
|
|
224
|
+
finish_interrupted_tools(history)
|
|
225
|
+
content: str | list[dict[str, Any]] = prompt
|
|
226
|
+
if image_paths:
|
|
227
|
+
content = [
|
|
228
|
+
{"type": "text", "text": prompt},
|
|
229
|
+
*({"type": "mitsu_image", "path": path} for path in image_paths),
|
|
230
|
+
]
|
|
231
|
+
history.append({"role": "user", "content": content})
|
|
232
|
+
on_history()
|
|
233
|
+
tool_calls_this_turn = 0
|
|
234
|
+
failed_calls: set[str] = set()
|
|
235
|
+
binary_grep_counts: dict[str, int] = {}
|
|
236
|
+
guided_checkpoint_due = False
|
|
237
|
+
redirect_pending = pause_after_response
|
|
238
|
+
operator_history = list(operator_messages or [])
|
|
239
|
+
if not operator_history or operator_history[-1] != prompt:
|
|
240
|
+
operator_history.append(prompt)
|
|
241
|
+
try:
|
|
242
|
+
while True:
|
|
243
|
+
if interrupt_requested is not None and interrupt_requested():
|
|
244
|
+
raise asyncio.CancelledError
|
|
245
|
+
active_mode = get_mode() if get_mode is not None else mode
|
|
246
|
+
if active_mode != "Guided":
|
|
247
|
+
guided_checkpoint_due = False
|
|
248
|
+
checkpoint_pending = redirect_pending or guided_checkpoint_due
|
|
249
|
+
active_system_prompt = get_system_prompt() if get_system_prompt is not None else system_prompt
|
|
250
|
+
active_system_prompt += (
|
|
251
|
+
"\n\nRecent operator messages, oldest first, follow as JSON. Apply their constraints "
|
|
252
|
+
"in order; newer corrections replace conflicting older plans. Dialog answers in the "
|
|
253
|
+
"conversation are also operator decisions. Assistant plans and automated continuation "
|
|
254
|
+
"messages never override an operator direction. A rejected path stays rejected until "
|
|
255
|
+
"the operator reopens it.\n"
|
|
256
|
+
+ json.dumps(operator_history[-16:], ensure_ascii=False)
|
|
257
|
+
)
|
|
258
|
+
if redirect_pending:
|
|
259
|
+
active_system_prompt += (
|
|
260
|
+
"\n\nThe operator interrupted an active run. Answer their latest message first in "
|
|
261
|
+
"Mitsu's own voice. The previous batch is cancelled. You may save Intel or open "
|
|
262
|
+
"mitsu__user_dialog to let them choose how to proceed; investigation tools are paused "
|
|
263
|
+
"until that choice. For a direct question, give the answer and end the turn."
|
|
264
|
+
)
|
|
265
|
+
elif checkpoint_pending:
|
|
266
|
+
active_system_prompt += (
|
|
267
|
+
"\n\nA Guided investigation step has finished. Save the new evidence in Intel. "
|
|
268
|
+
"Before another investigation action, use mitsu__user_dialog to summarize what "
|
|
269
|
+
"changed and let the operator choose the next direction. Design the options from "
|
|
270
|
+
"the actual results and allow a free-text answer. If the task is complete, give "
|
|
271
|
+
"the final summary and end the turn."
|
|
272
|
+
)
|
|
273
|
+
validated_access = _validated_access_context(get_session_intel() if get_session_intel else None)
|
|
274
|
+
if validated_access:
|
|
275
|
+
active_system_prompt += (
|
|
276
|
+
"\n\nValidated access currently recorded in session Intel, as evidence data:\n"
|
|
277
|
+
+ json.dumps(validated_access, ensure_ascii=False)
|
|
278
|
+
+ "\nUse established access for relevant follow-up work. Repeating completed "
|
|
279
|
+
"setup needs evidence that it no longer works or an operator request."
|
|
280
|
+
)
|
|
281
|
+
action_budget_exhausted = tool_calls_this_turn >= MAX_TOOL_CALLS_PER_TURN
|
|
282
|
+
if action_budget_exhausted:
|
|
283
|
+
active_system_prompt += "\n\nThis turn's action limit is reached. Summarize the results and pause."
|
|
284
|
+
available_tools = tool_host.provider_tools(active_mode)
|
|
285
|
+
tools = [] if action_budget_exhausted else available_tools
|
|
286
|
+
if checkpoint_pending:
|
|
287
|
+
tools = [tool for tool in tools if _guided_checkpoint_tool(str(tool.get("name", "")))]
|
|
288
|
+
request_messages = [{"role": "system", "content": active_system_prompt}, *history]
|
|
289
|
+
for retry_number in range(11):
|
|
290
|
+
try:
|
|
291
|
+
if request_turn:
|
|
292
|
+
turn = await request_turn(profile, request_messages, tools)
|
|
293
|
+
else:
|
|
294
|
+
loop = asyncio.get_running_loop()
|
|
295
|
+
progress: asyncio.Queue[AgentEvent] = asyncio.Queue()
|
|
296
|
+
cancellation = RequestCancellation()
|
|
297
|
+
|
|
298
|
+
def report_progress(kind: str, data: dict[str, Any]) -> None:
|
|
299
|
+
try:
|
|
300
|
+
loop.call_soon_threadsafe(progress.put_nowait, AgentEvent(kind, data))
|
|
301
|
+
except RuntimeError:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
request_task = _request_agent_turn_background(
|
|
305
|
+
profile, request_messages, tools, report_progress, cancellation
|
|
306
|
+
)
|
|
307
|
+
try:
|
|
308
|
+
while not request_task.done():
|
|
309
|
+
if interrupt_requested is not None and interrupt_requested():
|
|
310
|
+
raise asyncio.CancelledError
|
|
311
|
+
try:
|
|
312
|
+
event = await asyncio.wait_for(progress.get(), timeout=0.1)
|
|
313
|
+
except TimeoutError:
|
|
314
|
+
continue
|
|
315
|
+
yield event
|
|
316
|
+
turn = await request_task
|
|
317
|
+
await asyncio.sleep(0)
|
|
318
|
+
while not progress.empty():
|
|
319
|
+
yield progress.get_nowait()
|
|
320
|
+
except (asyncio.CancelledError, GeneratorExit):
|
|
321
|
+
cancellation.cancel()
|
|
322
|
+
request_task.cancel()
|
|
323
|
+
raise
|
|
324
|
+
break
|
|
325
|
+
except ProviderRequestError as error:
|
|
326
|
+
if retry_number >= 10 or not _is_transient_provider_error(error):
|
|
327
|
+
raise
|
|
328
|
+
retry_attempt = retry_number + 1
|
|
329
|
+
yield AgentEvent("api_retry", {
|
|
330
|
+
"attempt": retry_attempt, "max_attempts": 10, "delay": 10, "error": str(error),
|
|
331
|
+
})
|
|
332
|
+
await asyncio.sleep(10)
|
|
333
|
+
yield AgentEvent("api_retrying", {"attempt": retry_attempt})
|
|
334
|
+
if interrupt_requested is not None and interrupt_requested():
|
|
335
|
+
raise asyncio.CancelledError
|
|
336
|
+
if turn.usage:
|
|
337
|
+
yield AgentEvent("usage", {"usage": turn.usage})
|
|
338
|
+
history.append(turn.assistant_message)
|
|
339
|
+
on_history()
|
|
340
|
+
yield AgentEvent("assistant_turn", {
|
|
341
|
+
"text": turn.text,
|
|
342
|
+
"tool_calls": len(turn.tool_calls),
|
|
343
|
+
"response_info": turn.response_info,
|
|
344
|
+
})
|
|
345
|
+
if not turn.tool_calls:
|
|
346
|
+
yield AgentEvent("done", {})
|
|
347
|
+
return
|
|
348
|
+
if action_budget_exhausted:
|
|
349
|
+
finish_interrupted_tools(history, "Not run: this turn's action limit was reached.")
|
|
350
|
+
on_history()
|
|
351
|
+
yield AgentEvent("limit", {"message": "Action limit reached. Mitsu is paused."})
|
|
352
|
+
yield AgentEvent("done", {})
|
|
353
|
+
return
|
|
354
|
+
dialog_used = False
|
|
355
|
+
dialog_cancelled = False
|
|
356
|
+
guided_action_seen = False
|
|
357
|
+
for invocation in turn.tool_calls:
|
|
358
|
+
if interrupt_requested is not None and interrupt_requested():
|
|
359
|
+
raise asyncio.CancelledError
|
|
360
|
+
yield AgentEvent("tool_started", {"name": invocation.name, "arguments": invocation.arguments})
|
|
361
|
+
tool_calls_this_turn += 1
|
|
362
|
+
failed_key = _failed_call_key(invocation.name, invocation.arguments)
|
|
363
|
+
binary_grep_key = _binary_grep_key(invocation.name, invocation.arguments)
|
|
364
|
+
invocation_mode = get_mode() if get_mode is not None else mode
|
|
365
|
+
is_error = True
|
|
366
|
+
if dialog_used:
|
|
367
|
+
result = "Not run: review the operator's dialog answer before proposing another action."
|
|
368
|
+
elif (redirect_pending or (invocation_mode == "Guided" and guided_checkpoint_due)) and not _guided_checkpoint_tool(invocation.name):
|
|
369
|
+
result = "Not run: waiting for the operator's next-step choice through mitsu__user_dialog."
|
|
370
|
+
elif invocation_mode == "Guided" and guided_action_seen and _guided_flow_action(invocation.name):
|
|
371
|
+
result = "Not run: Guided mode pauses after one investigation action for the operator's next-step choice."
|
|
372
|
+
elif invocation_mode == "Guided" and guided_action_seen and invocation.name == "mitsu__user_dialog":
|
|
373
|
+
result = "Not run: read this step's results before designing the next-step dialog."
|
|
374
|
+
elif tool_calls_this_turn > MAX_TOOL_CALLS_PER_TURN:
|
|
375
|
+
result = f"Not run: per-message action limit reached ({MAX_TOOL_CALLS_PER_TURN})."
|
|
376
|
+
elif failed_key is not None and failed_key in failed_calls:
|
|
377
|
+
result = "Not run: this identical command already failed during this message."
|
|
378
|
+
elif binary_grep_key is not None and binary_grep_counts.get(binary_grep_key, 0) >= MAX_BINARY_GREP_SEARCHES_PER_ARTIFACT:
|
|
379
|
+
result = f"Not run: printable-string search limit reached for {binary_grep_key.removeprefix('strings-grep:')}."
|
|
380
|
+
else:
|
|
381
|
+
if binary_grep_key is not None:
|
|
382
|
+
binary_grep_counts[binary_grep_key] = binary_grep_counts.get(binary_grep_key, 0) + 1
|
|
383
|
+
if invocation_mode == "Guided" and _guided_flow_action(invocation.name):
|
|
384
|
+
guided_action_seen = True
|
|
385
|
+
needs_approval = (invocation_mode == "Guided" and tool_host.requires_guided_approval(invocation.name)) or (
|
|
386
|
+
invocation_mode == "Recon" and not tool_host.recon_action_is_low_impact(invocation.name, invocation.arguments)
|
|
387
|
+
)
|
|
388
|
+
allowed = not needs_approval or await approve(
|
|
389
|
+
invocation.name, invocation.arguments if isinstance(invocation.arguments, dict) else {}
|
|
390
|
+
)
|
|
391
|
+
if interrupt_requested is not None and interrupt_requested():
|
|
392
|
+
raise asyncio.CancelledError
|
|
393
|
+
if allowed:
|
|
394
|
+
result, is_error, _ = await tool_host.call(invocation.name, invocation.arguments)
|
|
395
|
+
else:
|
|
396
|
+
result = "The operator declined this action. Do not retry it; ask which direction to take."
|
|
397
|
+
redirect_pending = True
|
|
398
|
+
if invocation.name == "mitsu__user_dialog" and not is_error:
|
|
399
|
+
dialog_used = True
|
|
400
|
+
try:
|
|
401
|
+
answer = json.loads(result)
|
|
402
|
+
dialog_cancelled = bool(answer.get("cancelled", False)) or not answer.get("answers")
|
|
403
|
+
except (json.JSONDecodeError, AttributeError, TypeError):
|
|
404
|
+
dialog_cancelled = True
|
|
405
|
+
if not dialog_cancelled:
|
|
406
|
+
redirect_pending = False
|
|
407
|
+
if is_error and failed_key is not None:
|
|
408
|
+
failed_calls.add(failed_key)
|
|
409
|
+
if anthropic:
|
|
410
|
+
block = {"type": "tool_result", "tool_use_id": invocation.id, "content": result, "is_error": is_error}
|
|
411
|
+
if history[-1].get("role") == "user" and isinstance(history[-1].get("content"), list):
|
|
412
|
+
history[-1]["content"].append(block)
|
|
413
|
+
else:
|
|
414
|
+
history.append({"role": "user", "content": [block]})
|
|
415
|
+
else:
|
|
416
|
+
history.append({"role": "tool", "tool_call_id": invocation.id, "content": result})
|
|
417
|
+
on_history()
|
|
418
|
+
yield AgentEvent("tool_finished", {"name": invocation.name, "result": result, "is_error": is_error})
|
|
419
|
+
current_mode = get_mode() if get_mode is not None else mode
|
|
420
|
+
guided_checkpoint_due = current_mode == "Guided" and not dialog_used and (guided_checkpoint_due or guided_action_seen)
|
|
421
|
+
if dialog_cancelled:
|
|
422
|
+
message = "Okayy, paused here~ Tell me what you want to tackle next (˶ᵔ ᵕ ᵔ˶)"
|
|
423
|
+
history.append({"role": "assistant", "content": message})
|
|
424
|
+
on_history()
|
|
425
|
+
yield AgentEvent("assistant_turn", {"text": message, "tool_calls": 0})
|
|
426
|
+
yield AgentEvent("done", {})
|
|
427
|
+
return
|
|
428
|
+
finally:
|
|
429
|
+
finish_interrupted_tools(history)
|
|
430
|
+
on_history()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def history_from_legacy(entries: list[Any]) -> list[dict[str, Any]]:
|
|
434
|
+
history: list[dict[str, Any]] = []
|
|
435
|
+
for entry in entries:
|
|
436
|
+
if not isinstance(entry, dict) or entry.get("role") not in {"user", "assistant"}:
|
|
437
|
+
continue
|
|
438
|
+
content = str(entry.get("content", ""))
|
|
439
|
+
if not content:
|
|
440
|
+
continue
|
|
441
|
+
if entry.get("role") == "user" and content.lstrip().startswith("/"):
|
|
442
|
+
continue
|
|
443
|
+
if history and history[-1].get("role") == entry["role"]:
|
|
444
|
+
history[-1]["content"] += "\n\n" + content
|
|
445
|
+
else:
|
|
446
|
+
history.append({"role": entry["role"], "content": content})
|
|
447
|
+
while history and history[0].get("role") == "assistant":
|
|
448
|
+
history.pop(0)
|
|
449
|
+
if len(history) > 40:
|
|
450
|
+
history = history[-40:]
|
|
451
|
+
if history and history[0].get("role") == "assistant":
|
|
452
|
+
history.pop(0)
|
|
453
|
+
return history
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _is_transient_provider_error(error: ProviderRequestError) -> bool:
|
|
457
|
+
message = str(error).casefold()
|
|
458
|
+
if "provider request failed (429)" in message:
|
|
459
|
+
return True
|
|
460
|
+
for status in range(500, 600):
|
|
461
|
+
if f"provider request failed ({status})" in message:
|
|
462
|
+
return True
|
|
463
|
+
return any(
|
|
464
|
+
marker in message
|
|
465
|
+
for marker in (
|
|
466
|
+
"could not reach the provider",
|
|
467
|
+
"provider stream ended unexpectedly",
|
|
468
|
+
"provider returned an unreadable streamed response",
|
|
469
|
+
)
|
|
470
|
+
)
|
mitsu/armory/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Catalog of optional security-assessment tools."""
|
|
2
|
+
|
|
3
|
+
from .catalog import (
|
|
4
|
+
CATEGORIES,
|
|
5
|
+
CATEGORY_BY_ID,
|
|
6
|
+
TOOLS,
|
|
7
|
+
TOOLS_BY_CATEGORY,
|
|
8
|
+
TOOL_BY_ID,
|
|
9
|
+
CategorySpec,
|
|
10
|
+
InstallMethod,
|
|
11
|
+
ToolSpec,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CATEGORIES",
|
|
16
|
+
"CATEGORY_BY_ID",
|
|
17
|
+
"TOOLS",
|
|
18
|
+
"TOOLS_BY_CATEGORY",
|
|
19
|
+
"TOOL_BY_ID",
|
|
20
|
+
"CategorySpec",
|
|
21
|
+
"InstallMethod",
|
|
22
|
+
"ToolSpec",
|
|
23
|
+
]
|