sliceagent 0.1.0__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.
- sliceagent/__init__.py +3 -0
- sliceagent/__main__.py +6 -0
- sliceagent/access.py +93 -0
- sliceagent/agents.py +173 -0
- sliceagent/background_review.py +146 -0
- sliceagent/binsniff.py +89 -0
- sliceagent/cli.py +890 -0
- sliceagent/clock.py +32 -0
- sliceagent/code_grep.py +329 -0
- sliceagent/code_index.py +417 -0
- sliceagent/config.py +240 -0
- sliceagent/context_overflow.py +227 -0
- sliceagent/envspec.py +129 -0
- sliceagent/errors.py +167 -0
- sliceagent/events.py +96 -0
- sliceagent/finding_types.py +70 -0
- sliceagent/flags.py +63 -0
- sliceagent/fuzzy.py +135 -0
- sliceagent/guardrails.py +438 -0
- sliceagent/guidance.py +69 -0
- sliceagent/hippocampus.py +581 -0
- sliceagent/hooks.py +334 -0
- sliceagent/interfaces.py +144 -0
- sliceagent/llm.py +695 -0
- sliceagent/loop.py +548 -0
- sliceagent/mcp_client.py +255 -0
- sliceagent/mcp_security.py +77 -0
- sliceagent/memory.py +428 -0
- sliceagent/metrics.py +103 -0
- sliceagent/model_catalog.py +124 -0
- sliceagent/monitor.py +615 -0
- sliceagent/neocortex.py +436 -0
- sliceagent/onboarding.py +323 -0
- sliceagent/oracle.py +36 -0
- sliceagent/pagetable.py +255 -0
- sliceagent/pfc.py +449 -0
- sliceagent/plugins.py +127 -0
- sliceagent/policy.py +234 -0
- sliceagent/procman.py +187 -0
- sliceagent/prompt.py +239 -0
- sliceagent/records.py +108 -0
- sliceagent/recovery.py +119 -0
- sliceagent/regions.py +678 -0
- sliceagent/registry.py +128 -0
- sliceagent/retriever.py +19 -0
- sliceagent/safety.py +332 -0
- sliceagent/sandbox.py +143 -0
- sliceagent/scheduler.py +92 -0
- sliceagent/search_index.py +289 -0
- sliceagent/seed.py +465 -0
- sliceagent/sensory_cortex.py +500 -0
- sliceagent/session.py +222 -0
- sliceagent/skill_provenance.py +71 -0
- sliceagent/skill_usage.py +123 -0
- sliceagent/skills.py +209 -0
- sliceagent/subagent.py +332 -0
- sliceagent/subdir_hints.py +222 -0
- sliceagent/swap.py +182 -0
- sliceagent/taskstate.py +57 -0
- sliceagent/telemetry.py +59 -0
- sliceagent/terminal.py +240 -0
- sliceagent/text_utils.py +56 -0
- sliceagent/tool_summary.py +93 -0
- sliceagent/tools.py +1194 -0
- sliceagent/tui.py +1377 -0
- sliceagent/web.py +354 -0
- sliceagent-0.1.0.dist-info/METADATA +262 -0
- sliceagent-0.1.0.dist-info/RECORD +71 -0
- sliceagent-0.1.0.dist-info/WHEEL +4 -0
- sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
- sliceagent-0.1.0.dist-info/licenses/LICENSE +21 -0
sliceagent/mcp_client.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""MCP client — connect external MCP servers, surface their tools in the registry (③.3).
|
|
2
|
+
|
|
3
|
+
Uses `mcp__server__tool` namespacing + collision handling and an adapter
|
|
4
|
+
(MCP Tool → registry ToolEntry) + background-event-loop bridge. The official `mcp` SDK is
|
|
5
|
+
async and the agent loop is sync, so we run ONE asyncio loop in a daemon thread and submit
|
|
6
|
+
coroutines to it. Each server's connection lives in a SINGLE long-lived task (a worker that
|
|
7
|
+
opens the session, lists tools, then serves call requests off a queue) — keeping every
|
|
8
|
+
session op in one task sidesteps anyio's "cancel scope in different task" pitfall. A server
|
|
9
|
+
that fails to connect degrades to zero tools and never crashes the agent.
|
|
10
|
+
|
|
11
|
+
This phase supports the stdio transport (declared as [mcp_servers.<name>] with command/args).
|
|
12
|
+
HTTP/SSE transports are a later add behind the same McpServer seam.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import hashlib
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import threading
|
|
21
|
+
|
|
22
|
+
from .registry import ToolEntry, ToolText
|
|
23
|
+
|
|
24
|
+
_QUALIFY_MAX = 64
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def qualify(server: str, tool: str) -> str:
|
|
28
|
+
"""mcp__<server>__<tool>, sanitized to [A-Za-z0-9_], hash-truncated if too long."""
|
|
29
|
+
name = re.sub(r"[^A-Za-z0-9_]", "_", f"mcp__{server}__{tool}")
|
|
30
|
+
if len(name) > _QUALIFY_MAX:
|
|
31
|
+
h = hashlib.sha1(name.encode()).hexdigest()[:8]
|
|
32
|
+
name = name[: _QUALIFY_MAX - 9] + "_" + h
|
|
33
|
+
return name
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_MCP_SAFETY_CAP = 2_000_000 # last-resort OOM guard if a server streams megabytes. The REAL bounding is
|
|
37
|
+
# the host page-out applied in the handler: a big MCP result (browser/DB/
|
|
38
|
+
# Playwright payloads) goes to a blob + head/tail view, not inlined whole.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _result_to_text(result) -> str:
|
|
42
|
+
parts = []
|
|
43
|
+
for block in (getattr(result, "content", None) or []):
|
|
44
|
+
t = getattr(block, "text", None)
|
|
45
|
+
parts.append(t if t is not None else f"[{getattr(block, 'type', 'content')}]")
|
|
46
|
+
text = "\n".join(parts).strip() or "(no content)"
|
|
47
|
+
if len(text) > _MCP_SAFETY_CAP: # last-resort OOM guard; page-out (handler) does normal bounding
|
|
48
|
+
text = text[:_MCP_SAFETY_CAP] + f"\n…[truncated {len(text) - _MCP_SAFETY_CAP} chars of MCP output]"
|
|
49
|
+
# isError must propagate as ok=False so the loop's failing-detection + the anti-loop guardrail
|
|
50
|
+
# (repeated_exact_failure) actually see the failure — a plain "Error: …" string gets wrapped ok=True.
|
|
51
|
+
# (MCP results enter as role="tool" messages, which the model already treats as DATA at the protocol
|
|
52
|
+
# level — unlike web's slice re-injection channels — so an extra wrap_untrusted fence is low-value here.)
|
|
53
|
+
return ToolText(f"Error: {text}", ok=False) if getattr(result, "isError", False) else text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _mcp_handler(server, tool, page_out):
|
|
57
|
+
"""Tool handler that pages a LARGE MCP result OUT to a blob (a head/tail view + a read_file ref) rather
|
|
58
|
+
than inlining the whole payload — browser/DB/Playwright results can be hundreds of KB. The full output
|
|
59
|
+
is preserved on disk and paged back on demand (the moat's L1→L2 page-out), so nothing is lost. With no
|
|
60
|
+
host page_out (eval/headless), returns the raw text (already OOM-capped by _result_to_text)."""
|
|
61
|
+
def _handle(args):
|
|
62
|
+
out = server.call(tool, args)
|
|
63
|
+
ok = getattr(out, "ok", True) # capture BEFORE page_out — str.translate() (inside page_out's
|
|
64
|
+
# control-char strip) always returns a plain str, silently dropping the ToolText subclass and
|
|
65
|
+
# its .ok flag even on the success path. Re-wrapping with the captured `ok` below (not just on
|
|
66
|
+
# the ok=False branch) is what makes run_tool_batch's `getattr(out, "ok", None)` see an explicit
|
|
67
|
+
# flag instead of None — the None case was falling back to prose-matching ("Error"/"Exit code"
|
|
68
|
+
# prefix), which false-flagged a legitimate success result whose text happened to start that way.
|
|
69
|
+
if page_out:
|
|
70
|
+
try:
|
|
71
|
+
out = page_out(out, label=f"mcp-{tool}")
|
|
72
|
+
except Exception: # noqa: BLE001 — paging must never fail the tool call
|
|
73
|
+
pass
|
|
74
|
+
return ToolText(str(out), ok=ok)
|
|
75
|
+
return _handle
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _function_schema(qname: str, tool) -> dict:
|
|
79
|
+
params = getattr(tool, "inputSchema", None)
|
|
80
|
+
if not isinstance(params, dict) or params.get("type") != "object":
|
|
81
|
+
params = {"type": "object", "properties": {}}
|
|
82
|
+
desc = (getattr(tool, "description", None) or f"MCP tool {tool.name}").strip()
|
|
83
|
+
return {"type": "function", "function": {"name": qname, "description": desc[:1024], "parameters": params}}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class McpRuntime:
|
|
87
|
+
"""One background asyncio loop in a daemon thread; bridges sync ↔ async."""
|
|
88
|
+
|
|
89
|
+
def __init__(self):
|
|
90
|
+
self.loop = asyncio.new_event_loop()
|
|
91
|
+
self._thread = threading.Thread(target=self._run, name="mcp-loop", daemon=True)
|
|
92
|
+
self._thread.start()
|
|
93
|
+
|
|
94
|
+
def _run(self):
|
|
95
|
+
asyncio.set_event_loop(self.loop)
|
|
96
|
+
self.loop.run_forever()
|
|
97
|
+
|
|
98
|
+
def submit(self, coro, timeout):
|
|
99
|
+
cf = asyncio.run_coroutine_threadsafe(coro, self.loop)
|
|
100
|
+
try:
|
|
101
|
+
return cf.result(timeout)
|
|
102
|
+
except BaseException:
|
|
103
|
+
cf.cancel() # #60: a timed-out/failed call must stop its coroutine on the loop, not leak it
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
def spawn(self, coro):
|
|
107
|
+
return asyncio.run_coroutine_threadsafe(coro, self.loop)
|
|
108
|
+
|
|
109
|
+
def shutdown(self):
|
|
110
|
+
# #61/#62: cancel every task on the loop BEFORE stopping it, so each _serve()'s
|
|
111
|
+
# `async with stdio_client(...)`/`ClientSession` __aexit__ runs and its child PROCESS is
|
|
112
|
+
# terminated. Stopping the loop with tasks still pending would orphan those subprocesses.
|
|
113
|
+
async def _cancel_all():
|
|
114
|
+
tasks = [t for t in asyncio.all_tasks(self.loop) if t is not asyncio.current_task()]
|
|
115
|
+
for t in tasks:
|
|
116
|
+
t.cancel()
|
|
117
|
+
if tasks:
|
|
118
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
asyncio.run_coroutine_threadsafe(_cancel_all(), self.loop).result(timeout=5)
|
|
122
|
+
except BaseException: # noqa: BLE001 — best-effort teardown
|
|
123
|
+
pass
|
|
124
|
+
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class McpServer:
|
|
128
|
+
"""One MCP server connection, served by a single long-lived worker task on the runtime loop."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, name: str, runtime: McpRuntime):
|
|
131
|
+
self.name = name
|
|
132
|
+
self.runtime = runtime
|
|
133
|
+
self.tools: list = []
|
|
134
|
+
self.error: str | None = None
|
|
135
|
+
self._queue: asyncio.Queue | None = None
|
|
136
|
+
self._ready: asyncio.Event | None = None
|
|
137
|
+
|
|
138
|
+
def connect(self, params, timeout: float = 30) -> list:
|
|
139
|
+
self.runtime.submit(self._mk_primitives(), timeout) # create loop-bound queue/event
|
|
140
|
+
self.runtime.spawn(self._serve(params)) # start the worker (long-lived)
|
|
141
|
+
self.runtime.submit(self._wait_ready(), timeout) # block until tools listed or error
|
|
142
|
+
if self.error:
|
|
143
|
+
raise RuntimeError(self.error)
|
|
144
|
+
return self.tools
|
|
145
|
+
|
|
146
|
+
async def _mk_primitives(self):
|
|
147
|
+
self._queue = asyncio.Queue()
|
|
148
|
+
self._ready = asyncio.Event()
|
|
149
|
+
|
|
150
|
+
async def _wait_ready(self):
|
|
151
|
+
await self._ready.wait()
|
|
152
|
+
|
|
153
|
+
async def _serve(self, params):
|
|
154
|
+
from mcp import ClientSession
|
|
155
|
+
from mcp.client.stdio import stdio_client
|
|
156
|
+
try:
|
|
157
|
+
async with stdio_client(params) as (read, write):
|
|
158
|
+
async with ClientSession(read, write) as session:
|
|
159
|
+
await session.initialize()
|
|
160
|
+
self.tools = list((await session.list_tools()).tools)
|
|
161
|
+
self._ready.set()
|
|
162
|
+
while True:
|
|
163
|
+
req = await self._queue.get()
|
|
164
|
+
if req is None:
|
|
165
|
+
break
|
|
166
|
+
tool, args, fut = req
|
|
167
|
+
try:
|
|
168
|
+
res = await session.call_tool(tool, args or {})
|
|
169
|
+
if not fut.done():
|
|
170
|
+
fut.set_result(res)
|
|
171
|
+
except Exception as e: # noqa: BLE001
|
|
172
|
+
if not fut.done():
|
|
173
|
+
fut.set_exception(e)
|
|
174
|
+
except Exception as e: # noqa: BLE001 — connect/transport failure
|
|
175
|
+
self.error = str(e)
|
|
176
|
+
if self._ready is not None and not self._ready.is_set():
|
|
177
|
+
self._ready.set()
|
|
178
|
+
|
|
179
|
+
def call(self, tool: str, args: dict, timeout: float = 60) -> str:
|
|
180
|
+
if self.error:
|
|
181
|
+
return ToolText(f"Error: MCP server {self.name!r} unavailable: {self.error}", ok=False)
|
|
182
|
+
|
|
183
|
+
async def _do():
|
|
184
|
+
fut = self.runtime.loop.create_future()
|
|
185
|
+
await self._queue.put((tool, args, fut))
|
|
186
|
+
return await fut
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
return _result_to_text(self.runtime.submit(_do(), timeout))
|
|
190
|
+
except Exception as e: # noqa: BLE001
|
|
191
|
+
return ToolText(f"Error: MCP call {self.name}.{tool} failed: {e}", ok=False)
|
|
192
|
+
|
|
193
|
+
def close(self):
|
|
194
|
+
try:
|
|
195
|
+
self.runtime.submit(self._queue.put(None), 5)
|
|
196
|
+
except Exception: # noqa: BLE001
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _params_from_conf(conf: dict):
|
|
201
|
+
from mcp import StdioServerParameters
|
|
202
|
+
env = None
|
|
203
|
+
if conf.get("env"):
|
|
204
|
+
env = {**os.environ, **conf["env"]}
|
|
205
|
+
return StdioServerParameters(command=conf["command"], args=list(conf.get("args", [])),
|
|
206
|
+
env=env, cwd=conf.get("cwd"))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def connect_mcp_servers(registry, servers: dict, runtime: McpRuntime | None = None,
|
|
210
|
+
*, timeout: float = 30, on_log=None, page_out=None):
|
|
211
|
+
"""Connect each declared server and register its tools into `registry` (namespaced).
|
|
212
|
+
Returns (connected_servers, runtime). Returns ([], None) when nothing is configured."""
|
|
213
|
+
def log(m):
|
|
214
|
+
if on_log:
|
|
215
|
+
on_log(m)
|
|
216
|
+
|
|
217
|
+
if not servers:
|
|
218
|
+
return [], None
|
|
219
|
+
runtime = runtime or McpRuntime()
|
|
220
|
+
connected, total = [], 0
|
|
221
|
+
for name, conf in servers.items():
|
|
222
|
+
if not isinstance(conf, dict) or not conf.get("command"):
|
|
223
|
+
log(f"mcp:{name} skipped (only stdio with a 'command' is supported in this phase)")
|
|
224
|
+
continue
|
|
225
|
+
from .mcp_security import validate_mcp_server_entry # screen BEFORE spawning (RCE-by-design surface)
|
|
226
|
+
_bad = validate_mcp_server_entry(name, conf)
|
|
227
|
+
if _bad:
|
|
228
|
+
for _b in _bad:
|
|
229
|
+
log(f"mcp:{name} REFUSED (security) — {_b}")
|
|
230
|
+
continue
|
|
231
|
+
server = McpServer(name, runtime)
|
|
232
|
+
try:
|
|
233
|
+
tools = server.connect(_params_from_conf(conf), timeout)
|
|
234
|
+
except Exception as e: # noqa: BLE001
|
|
235
|
+
log(f"mcp:{name} connect failed: {e}")
|
|
236
|
+
continue
|
|
237
|
+
inc, exc = set(conf.get("include") or []), set(conf.get("exclude") or [])
|
|
238
|
+
n = 0
|
|
239
|
+
for tool in tools:
|
|
240
|
+
if (inc and tool.name not in inc) or tool.name in exc:
|
|
241
|
+
continue
|
|
242
|
+
qname = qualify(name, tool.name)
|
|
243
|
+
if registry.has(qname):
|
|
244
|
+
log(f"mcp:{name} tool {qname} collides with an existing tool, skipped")
|
|
245
|
+
continue
|
|
246
|
+
registry.register(ToolEntry(
|
|
247
|
+
name=qname, schema=_function_schema(qname, tool),
|
|
248
|
+
handler=_mcp_handler(server, tool.name, page_out),
|
|
249
|
+
source="mcp",
|
|
250
|
+
))
|
|
251
|
+
n += 1
|
|
252
|
+
connected.append(server)
|
|
253
|
+
total += n
|
|
254
|
+
log(f"mcp:{name} connected ({n} tools)")
|
|
255
|
+
return connected, runtime
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Security screen for user-configured MCP server entries.
|
|
2
|
+
|
|
3
|
+
MCP stdio transports intentionally allow ARBITRARY local commands — a configured server is remote-code-
|
|
4
|
+
execution by design. We don't try to sandbox that. We refuse two high-signal ABUSE SHAPES that a real MCP
|
|
5
|
+
server never has, so a hand-edited or pre-planted config.toml is caught BEFORE `connect_mcp_servers` spawns it:
|
|
6
|
+
|
|
7
|
+
1. a shell interpreter whose inline script performs NETWORK EGRESS (curl/wget/nc/socat, /dev/tcp,
|
|
8
|
+
PowerShell web clients) — the exfiltration shape;
|
|
9
|
+
2. a shell interpreter whose inline script writes to an OS PERSISTENCE surface (SSH keys, PAM, sudoers,
|
|
10
|
+
cron, init units, shell rc files) — the backdoor shape.
|
|
11
|
+
|
|
12
|
+
This is intentionally NOT a whitelist: legitimate local MCPs using npx / uvx / python / a custom binary all
|
|
13
|
+
pass. General + task-agnostic — only the shell-interpreter-plus-egress/persistence combination is refused.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shlex
|
|
20
|
+
|
|
21
|
+
_SHELL_INTERPRETERS = frozenset({
|
|
22
|
+
"bash", "sh", "zsh", "dash", "fish", "ksh",
|
|
23
|
+
"cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe",
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
_EGRESS = re.compile(
|
|
27
|
+
r"(?<![\w.-])(?:curl|wget|nc|ncat|socat)(?![\w.-])"
|
|
28
|
+
r"|/dev/tcp/"
|
|
29
|
+
r"|\bInvoke-WebRequest\b|\bInvoke-RestMethod\b|\bSystem\.Net\.WebClient\b",
|
|
30
|
+
re.IGNORECASE,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_PERSISTENCE = re.compile(
|
|
34
|
+
r"authorized_keys|\.ssh/|/etc/ssh\b"
|
|
35
|
+
r"|/etc/pam\.d\b|pam_[\w-]+\.so|/etc/sudoers"
|
|
36
|
+
r"|/etc/cron|crontab\b|/etc/rc\.local|/etc/systemd"
|
|
37
|
+
r"|\.bashrc\b|\.bash_profile\b|\.profile\b|\.zshrc\b",
|
|
38
|
+
re.IGNORECASE,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _script(args) -> str:
|
|
43
|
+
if args is None:
|
|
44
|
+
return ""
|
|
45
|
+
if isinstance(args, (list, tuple)):
|
|
46
|
+
return " ".join(str(a) for a in args)
|
|
47
|
+
return str(args)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_mcp_server_entry(name: str, conf) -> list[str]:
|
|
51
|
+
"""Return a list of security objections to spawning this MCP entry (empty list = clean).
|
|
52
|
+
|
|
53
|
+
Only a shell interpreter (bash/sh/pwsh/…) carrying an inline script with network-egress OR
|
|
54
|
+
OS-persistence content is refused; everything else (npx/uvx/python/custom binaries) passes.
|
|
55
|
+
"""
|
|
56
|
+
if not isinstance(conf, dict):
|
|
57
|
+
return []
|
|
58
|
+
# Tokenize command + args together and check whether ANY token is a shell interpreter — so a wrapped
|
|
59
|
+
# interpreter (env bash -c …, /usr/bin/timeout 5 sh -c …, a full path) is screened, not just a bare
|
|
60
|
+
# `command: bash`. Then scan the FULL command+args text for the egress/persistence shapes.
|
|
61
|
+
full = (str(conf.get("command") or "") + " " + _script(conf.get("args"))).strip()
|
|
62
|
+
if not full:
|
|
63
|
+
return []
|
|
64
|
+
try:
|
|
65
|
+
tokens = shlex.split(full, posix=(os.name != "nt"))
|
|
66
|
+
except ValueError:
|
|
67
|
+
tokens = full.split()
|
|
68
|
+
if not any(os.path.basename(t).lower() in _SHELL_INTERPRETERS for t in tokens):
|
|
69
|
+
return []
|
|
70
|
+
issues: list[str] = []
|
|
71
|
+
if _EGRESS.search(full):
|
|
72
|
+
issues.append(f"MCP server '{name}': a shell interpreter with network-egress arguments "
|
|
73
|
+
"(exfiltration shape — not a real MCP server)")
|
|
74
|
+
if _PERSISTENCE.search(full):
|
|
75
|
+
issues.append(f"MCP server '{name}': a shell interpreter writing to an OS persistence surface "
|
|
76
|
+
"(SSH keys / PAM / sudoers / cron / shell rc — backdoor shape, not a real MCP server)")
|
|
77
|
+
return issues
|