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.
Files changed (71) hide show
  1. sliceagent/__init__.py +3 -0
  2. sliceagent/__main__.py +6 -0
  3. sliceagent/access.py +93 -0
  4. sliceagent/agents.py +173 -0
  5. sliceagent/background_review.py +146 -0
  6. sliceagent/binsniff.py +89 -0
  7. sliceagent/cli.py +890 -0
  8. sliceagent/clock.py +32 -0
  9. sliceagent/code_grep.py +329 -0
  10. sliceagent/code_index.py +417 -0
  11. sliceagent/config.py +240 -0
  12. sliceagent/context_overflow.py +227 -0
  13. sliceagent/envspec.py +129 -0
  14. sliceagent/errors.py +167 -0
  15. sliceagent/events.py +96 -0
  16. sliceagent/finding_types.py +70 -0
  17. sliceagent/flags.py +63 -0
  18. sliceagent/fuzzy.py +135 -0
  19. sliceagent/guardrails.py +438 -0
  20. sliceagent/guidance.py +69 -0
  21. sliceagent/hippocampus.py +581 -0
  22. sliceagent/hooks.py +334 -0
  23. sliceagent/interfaces.py +144 -0
  24. sliceagent/llm.py +695 -0
  25. sliceagent/loop.py +548 -0
  26. sliceagent/mcp_client.py +255 -0
  27. sliceagent/mcp_security.py +77 -0
  28. sliceagent/memory.py +428 -0
  29. sliceagent/metrics.py +103 -0
  30. sliceagent/model_catalog.py +124 -0
  31. sliceagent/monitor.py +615 -0
  32. sliceagent/neocortex.py +436 -0
  33. sliceagent/onboarding.py +323 -0
  34. sliceagent/oracle.py +36 -0
  35. sliceagent/pagetable.py +255 -0
  36. sliceagent/pfc.py +449 -0
  37. sliceagent/plugins.py +127 -0
  38. sliceagent/policy.py +234 -0
  39. sliceagent/procman.py +187 -0
  40. sliceagent/prompt.py +239 -0
  41. sliceagent/records.py +108 -0
  42. sliceagent/recovery.py +119 -0
  43. sliceagent/regions.py +678 -0
  44. sliceagent/registry.py +128 -0
  45. sliceagent/retriever.py +19 -0
  46. sliceagent/safety.py +332 -0
  47. sliceagent/sandbox.py +143 -0
  48. sliceagent/scheduler.py +92 -0
  49. sliceagent/search_index.py +289 -0
  50. sliceagent/seed.py +465 -0
  51. sliceagent/sensory_cortex.py +500 -0
  52. sliceagent/session.py +222 -0
  53. sliceagent/skill_provenance.py +71 -0
  54. sliceagent/skill_usage.py +123 -0
  55. sliceagent/skills.py +209 -0
  56. sliceagent/subagent.py +332 -0
  57. sliceagent/subdir_hints.py +222 -0
  58. sliceagent/swap.py +182 -0
  59. sliceagent/taskstate.py +57 -0
  60. sliceagent/telemetry.py +59 -0
  61. sliceagent/terminal.py +240 -0
  62. sliceagent/text_utils.py +56 -0
  63. sliceagent/tool_summary.py +93 -0
  64. sliceagent/tools.py +1194 -0
  65. sliceagent/tui.py +1377 -0
  66. sliceagent/web.py +354 -0
  67. sliceagent-0.1.0.dist-info/METADATA +262 -0
  68. sliceagent-0.1.0.dist-info/RECORD +71 -0
  69. sliceagent-0.1.0.dist-info/WHEEL +4 -0
  70. sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
  71. sliceagent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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