virtuai-cli 1.0.7__tar.gz → 1.0.9__tar.gz
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.
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/PKG-INFO +1 -1
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/pyproject.toml +1 -1
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/__init__.py +1 -1
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/look.py +8 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/steps.py +25 -11
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/tui.py +85 -11
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/widgets.py +5 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/command_guard.py +68 -8
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/runner.py +4 -1
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_command_guard.py +51 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/README.md +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/setup.cfg +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/browser.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/agents.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/ask.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/clipboard.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/command.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/devtools.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/highlight.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/journal.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/panels.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/review.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/sse.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/guard_setup.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/main.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/ollama_state.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/omlx_state.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/opencode.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/pull.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli.egg-info/requires.txt +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli.egg-info/top_level.txt +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_browser.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_frame_concurrency.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_guard_setup.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_ollama_options.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_ollama_state.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_omlx.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_process_group_kill.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_proxy_env.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_pull_fallback.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_run_single_instance.py +0 -0
- {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_session_isolation.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""VirtuAI local CLI."""
|
|
2
|
-
__version__ = "1.0.
|
|
2
|
+
__version__ = "1.0.9"
|
|
@@ -190,6 +190,14 @@ T = {
|
|
|
190
190
|
"guard_on": "🛡 comandos revisados por {model}" if _ES else "🛡 commands checked by {model}",
|
|
191
191
|
"guard_rules_only": "🛡 {why} — se pedirá permiso para lo que no sea lectura" if _ES
|
|
192
192
|
else "🛡 {why} — anything but reads will be asked",
|
|
193
|
+
"ran_runner": "💻 {name}",
|
|
194
|
+
"ran_workstation": "📦 {name}",
|
|
195
|
+
"ran_browser": "🌐 navegador" if _ES else "🌐 browser",
|
|
196
|
+
"guard_read_title": "El agente quiere leer fuera de la carpeta" if _ES else "The agent wants to read outside the folder",
|
|
197
|
+
"guard_read_allow": "Permitir esta vez" if _ES else "Allow this once",
|
|
198
|
+
"guard_read_deny": "No" if _ES else "No",
|
|
199
|
+
"guard_read_dir": "Permitir lecturas en {dir} durante esta sesión" if _ES else "Allow reads in {dir} for this session",
|
|
200
|
+
"guard_you": "tú lo autorizaste" if _ES else "you authorized it",
|
|
193
201
|
"guard_off": "⚠ comandos sin revisar" if _ES else "⚠ commands unchecked",
|
|
194
202
|
"guard_off_note": ("sin clasificador: lo que ejecute el agente es tu responsabilidad (virtuai guard setup)"
|
|
195
203
|
if _ES else "no classifier: what the agent runs is your responsibility (virtuai guard setup)"),
|
|
@@ -456,14 +456,18 @@ class CommandGuardPrompt(Vertical):
|
|
|
456
456
|
BINDINGS = [
|
|
457
457
|
Binding("1", "choose(0)", show=False),
|
|
458
458
|
Binding("2", "choose(1)", show=False),
|
|
459
|
+
Binding("3", "choose(2)", show=False),
|
|
459
460
|
]
|
|
460
461
|
|
|
461
|
-
def __init__(self, command: str, verdict_text: str) -> None:
|
|
462
|
+
def __init__(self, command: str, verdict_text: str, *, read_dir: Optional[str] = None) -> None:
|
|
462
463
|
super().__init__()
|
|
463
464
|
self.command = command
|
|
464
465
|
self.verdict_text = verdict_text
|
|
465
|
-
|
|
466
|
-
self.
|
|
466
|
+
#: A read outside the workdir: its folder can be allowed for the session.
|
|
467
|
+
self.read_dir = read_dir
|
|
468
|
+
self.border_title = f"⚠ {T['guard_read_title'] if read_dir else T['guard_title']}"
|
|
469
|
+
#: "allow", "deny" or "dir".
|
|
470
|
+
self.result: "asyncio.Future[str]" = asyncio.get_event_loop().create_future()
|
|
467
471
|
|
|
468
472
|
def compose(self):
|
|
469
473
|
from rich.syntax import Syntax
|
|
@@ -479,8 +483,13 @@ class CommandGuardPrompt(Vertical):
|
|
|
479
483
|
t.append(label, style="bold")
|
|
480
484
|
return t
|
|
481
485
|
|
|
482
|
-
|
|
483
|
-
|
|
486
|
+
if self.read_dir:
|
|
487
|
+
yield OptionList(Option(row("1", T["guard_read_allow"]), id="allow"),
|
|
488
|
+
Option(row("2", T["guard_read_deny"]), id="deny"),
|
|
489
|
+
Option(row("3", T["guard_read_dir"].format(dir=self.read_dir)), id="dir"))
|
|
490
|
+
else:
|
|
491
|
+
yield OptionList(Option(row("1", T["guard_allow"]), id="allow"),
|
|
492
|
+
Option(row("2", T["guard_deny"]), id="deny"))
|
|
484
493
|
|
|
485
494
|
def on_mount(self) -> None:
|
|
486
495
|
ol = self.query_one(OptionList)
|
|
@@ -488,17 +497,22 @@ class CommandGuardPrompt(Vertical):
|
|
|
488
497
|
ol.focus()
|
|
489
498
|
|
|
490
499
|
def action_choose(self, index: int) -> None:
|
|
491
|
-
self.
|
|
500
|
+
choices = ["allow", "deny"] + (["dir"] if self.read_dir else [])
|
|
501
|
+
if index < len(choices):
|
|
502
|
+
self._answer(choices[index])
|
|
492
503
|
|
|
493
504
|
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
494
505
|
event.stop()
|
|
495
|
-
self._answer(event.option.id
|
|
506
|
+
self._answer(event.option.id or "deny")
|
|
496
507
|
|
|
497
|
-
def _answer(self,
|
|
508
|
+
def _answer(self, choice: str, note: str = "") -> None:
|
|
498
509
|
if self.result.done():
|
|
499
510
|
return
|
|
500
|
-
self.result.set_result(
|
|
501
|
-
|
|
511
|
+
self.result.set_result(choice)
|
|
512
|
+
allowed = choice in ("allow", "dir")
|
|
513
|
+
if not note:
|
|
514
|
+
note = T["guard_allowed"] if allowed else (T["guard_read_deny"] if self.read_dir else T["guard_denied"])
|
|
515
|
+
self.settle(note)
|
|
502
516
|
|
|
503
517
|
def settle(self, note: str) -> None:
|
|
504
518
|
self.add_class("-answered")
|
|
@@ -512,5 +526,5 @@ class CommandGuardPrompt(Vertical):
|
|
|
512
526
|
|
|
513
527
|
def expire(self) -> None:
|
|
514
528
|
if not self.result.done():
|
|
515
|
-
self.result.set_result(
|
|
529
|
+
self.result.set_result("deny")
|
|
516
530
|
self.settle(T["guard_expired"])
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import json
|
|
7
|
+
import os
|
|
7
8
|
import time
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Optional
|
|
@@ -260,6 +261,7 @@ class ChatApp(App):
|
|
|
260
261
|
# what you run with ! goes back to the agent.
|
|
261
262
|
self._guard_on = command_guard.enabled()
|
|
262
263
|
self._authorized: dict[str, float] = {} # command -> when, for the runner
|
|
264
|
+
self._read_dirs: set = set() # folders you let it read this session
|
|
263
265
|
self._handoff: Optional[str] = None # the refused command, if you run it
|
|
264
266
|
self._shell_context: list[str] = []
|
|
265
267
|
self._turn_plan = False # the turn streaming now asked for plan mode
|
|
@@ -1923,14 +1925,22 @@ class ChatApp(App):
|
|
|
1923
1925
|
ok, what = await command_guard.available()
|
|
1924
1926
|
self._set_status(T["guard_on"].format(model=what) if ok else T["guard_rules_only"].format(why=what))
|
|
1925
1927
|
|
|
1926
|
-
async def _guard_decide(self, command: str, timeout: float
|
|
1927
|
-
|
|
1928
|
+
async def _guard_decide(self, command: str, timeout: float,
|
|
1929
|
+
read_path: Optional[str] = None) -> tuple[bool, str, bool]:
|
|
1930
|
+
"""(run it?, what the guard thought, were you asked?). Asks you when
|
|
1931
|
+
it is not clearly safe. `read_path`: the agent reading a file outside
|
|
1932
|
+
the workdir — shown as the command that would read it, with the
|
|
1933
|
+
option to allow its folder for the session."""
|
|
1928
1934
|
verdict = await command_guard.classify(command, str(self.workdir))
|
|
1929
1935
|
if verdict.safe:
|
|
1930
|
-
return True, verdict.describe()
|
|
1936
|
+
return True, verdict.describe(), False
|
|
1931
1937
|
from virtuai_cli.chat.steps import CommandGuardPrompt
|
|
1932
1938
|
|
|
1933
|
-
|
|
1939
|
+
read_dir = None
|
|
1940
|
+
if read_path is not None:
|
|
1941
|
+
p = Path(os.path.expanduser(read_path))
|
|
1942
|
+
read_dir = str(p if p.suffix == "" and not p.name.startswith(".") and p.is_dir() else p.parent)
|
|
1943
|
+
prompt = CommandGuardPrompt(command, verdict.describe(), read_dir=read_dir)
|
|
1934
1944
|
target = self._current_turn if self._current_turn is not None and self._current_turn.is_mounted else None
|
|
1935
1945
|
if target is not None:
|
|
1936
1946
|
await target.mount(prompt)
|
|
@@ -1941,18 +1951,21 @@ class ChatApp(App):
|
|
|
1941
1951
|
if not self._focused:
|
|
1942
1952
|
devtools.notify(T["guard_title"], command[:120])
|
|
1943
1953
|
try:
|
|
1944
|
-
|
|
1954
|
+
choice = await asyncio.wait_for(asyncio.shield(prompt.result), timeout)
|
|
1945
1955
|
except asyncio.TimeoutError:
|
|
1946
1956
|
prompt.expire()
|
|
1947
|
-
|
|
1948
|
-
|
|
1957
|
+
choice = "deny"
|
|
1958
|
+
allowed = choice in ("allow", "dir")
|
|
1959
|
+
if choice == "dir" and read_dir:
|
|
1960
|
+
self._read_dirs.add(str(Path(read_dir).resolve()))
|
|
1961
|
+
if not allowed and read_path is None:
|
|
1949
1962
|
self._handoff = command
|
|
1950
1963
|
inp = self.query_one(ChatInput)
|
|
1951
1964
|
if not inp.text.strip():
|
|
1952
1965
|
inp.text = f"!{command}"
|
|
1953
1966
|
inp.move_cursor(inp.document.end)
|
|
1954
1967
|
self.query_one(ChatInput).focus()
|
|
1955
|
-
return allowed, verdict.describe()
|
|
1968
|
+
return allowed, verdict.describe(), True
|
|
1956
1969
|
|
|
1957
1970
|
@staticmethod
|
|
1958
1971
|
def _refusal(command: str, why: str) -> str:
|
|
@@ -1966,13 +1979,63 @@ class ChatApp(App):
|
|
|
1966
1979
|
"another way — wait, or continue without it."
|
|
1967
1980
|
)
|
|
1968
1981
|
|
|
1982
|
+
def _mark_card(self, command: str, note: str, asked: bool) -> None:
|
|
1983
|
+
"""Show on the command's step what the guard decided. The runner's
|
|
1984
|
+
frame ids are not the tool's, so the step is found by its command."""
|
|
1985
|
+
turn = self._current_turn
|
|
1986
|
+
if turn is None:
|
|
1987
|
+
return
|
|
1988
|
+
from virtuai_cli.chat.widgets import ToolCallCard
|
|
1989
|
+
|
|
1990
|
+
def command_of(card) -> str:
|
|
1991
|
+
text = card.input_str.strip()
|
|
1992
|
+
if text.startswith("{"):
|
|
1993
|
+
try:
|
|
1994
|
+
import json
|
|
1995
|
+
|
|
1996
|
+
return str(json.loads(text).get("command") or "").strip()
|
|
1997
|
+
except (ValueError, AttributeError):
|
|
1998
|
+
pass
|
|
1999
|
+
return text
|
|
2000
|
+
|
|
2001
|
+
for card in reversed(list(turn.query(ToolCallCard))):
|
|
2002
|
+
if not card.guard and command_of(card) == command.strip():
|
|
2003
|
+
card.guard, card.guard_asked = note, asked
|
|
2004
|
+
card._redraw()
|
|
2005
|
+
return
|
|
2006
|
+
|
|
2007
|
+
async def _guard_read(self, path: str, frame: dict) -> Optional[str]:
|
|
2008
|
+
"""The agent reading a file or listing a folder: free in the workdir
|
|
2009
|
+
and in folders you allowed; elsewhere you are asked, with what Tev1
|
|
2010
|
+
makes of `cat <path>` (reading ~/.aws is "secrets")."""
|
|
2011
|
+
if command_guard.is_inside(path, str(self.workdir)):
|
|
2012
|
+
return None
|
|
2013
|
+
resolved = Path(os.path.expanduser(path)).resolve()
|
|
2014
|
+
if any(resolved == Path(d) or resolved.is_relative_to(d) for d in self._read_dirs):
|
|
2015
|
+
return None
|
|
2016
|
+
import shlex
|
|
2017
|
+
|
|
2018
|
+
timeout = max(30.0, float(frame.get("timeout_secs") or 300) - 15)
|
|
2019
|
+
allowed, why, _ = await self._guard_decide(f"cat {shlex.quote(path)}", timeout, read_path=path)
|
|
2020
|
+
if allowed:
|
|
2021
|
+
return None
|
|
2022
|
+
# As the read's own script reports an error (one JSON line), so
|
|
2023
|
+
# BaseSandbox's ls/read/glob hand it to the agent as the reason.
|
|
2024
|
+
return json.dumps({"error": (
|
|
2025
|
+
f"not read: it is outside the workdir ({self.workdir}) and the user did not allow it. "
|
|
2026
|
+
"Work inside the workdir, or ask the user for what you need from there.")})
|
|
2027
|
+
|
|
1969
2028
|
async def _guard_command(self, command: str, frame: dict) -> Optional[str]:
|
|
1970
2029
|
"""The runner's COMMAND_GATE: None runs it, a string goes back instead."""
|
|
2030
|
+
if frame.get("purpose") == "read":
|
|
2031
|
+
return await self._guard_read(str(frame.get("path") or "."), frame)
|
|
1971
2032
|
when = self._authorized.pop(command, None)
|
|
1972
2033
|
if when is not None and time.monotonic() - when < 300:
|
|
2034
|
+
self._mark_card(command, T["guard_you"], True)
|
|
1973
2035
|
return None # you already said yes to it, through the server's prompt
|
|
1974
2036
|
timeout = max(30.0, float(frame.get("timeout_secs") or 300) - 15)
|
|
1975
|
-
allowed, why = await self._guard_decide(command, timeout)
|
|
2037
|
+
allowed, why, asked = await self._guard_decide(command, timeout)
|
|
2038
|
+
self._mark_card(command, T["guard_you"] if asked and allowed else why, asked)
|
|
1976
2039
|
return None if allowed else self._refusal(command, why)
|
|
1977
2040
|
|
|
1978
2041
|
async def _guard_approval(self, event: dict) -> None:
|
|
@@ -1986,7 +2049,7 @@ class ChatApp(App):
|
|
|
1986
2049
|
allowed = True
|
|
1987
2050
|
else:
|
|
1988
2051
|
timeout = max(30.0, float(event.get("timeout_secs") or 300) - 15)
|
|
1989
|
-
allowed, why = await self._guard_decide(command, timeout)
|
|
2052
|
+
allowed, why, _ = await self._guard_decide(command, timeout)
|
|
1990
2053
|
if allowed:
|
|
1991
2054
|
self._authorized[command] = time.monotonic()
|
|
1992
2055
|
await self._post_approval(request_id, allowed, "" if allowed else self._why_not(why))
|
|
@@ -2314,13 +2377,24 @@ class ChatApp(App):
|
|
|
2314
2377
|
await turn.set_todos(step.get("items", []))
|
|
2315
2378
|
# usage, model_end, cancelled: nothing to show (web chat shows nothing either).
|
|
2316
2379
|
|
|
2380
|
+
@staticmethod
|
|
2381
|
+
def _where(loc) -> str:
|
|
2382
|
+
"""Where a step ran, as web chat says it — not the raw dict."""
|
|
2383
|
+
if isinstance(loc, dict):
|
|
2384
|
+
kind = loc.get("kind")
|
|
2385
|
+
if kind == "browser":
|
|
2386
|
+
return T["ran_browser"]
|
|
2387
|
+
key = "ran_workstation" if kind == "workstation" else "ran_runner"
|
|
2388
|
+
return T[key].format(name=loc.get("name") or kind or "")
|
|
2389
|
+
return str(loc or "")
|
|
2390
|
+
|
|
2317
2391
|
@staticmethod
|
|
2318
2392
|
def _decorate_card(card, event: dict) -> None:
|
|
2319
2393
|
"""What web chat shows on a step besides its name: where it ran, the
|
|
2320
2394
|
command's language, which knowledge base a search read."""
|
|
2321
2395
|
if card is None or not hasattr(card, "ran_in"):
|
|
2322
2396
|
return
|
|
2323
|
-
card.ran_in = event.get("ran_in")
|
|
2397
|
+
card.ran_in = ChatApp._where(event.get("ran_in"))
|
|
2324
2398
|
card.language = event.get("language") or ("bash" if event.get("display") == "execute" else "")
|
|
2325
2399
|
if event.get("display") == "kb_search" and event.get("kb_name"):
|
|
2326
2400
|
card.tool_name = f"📚 {event['kb_name']}"
|
|
@@ -797,6 +797,9 @@ class ToolCallCard(ExpandableCard):
|
|
|
797
797
|
self.ran_in: str = ""
|
|
798
798
|
self.elapsed: Optional[float] = None
|
|
799
799
|
self.language: str = ""
|
|
800
|
+
# What the command guard decided (command_guard.py), when it ran here.
|
|
801
|
+
self.guard: str = ""
|
|
802
|
+
self.guard_asked = False
|
|
800
803
|
self.add_class("-running")
|
|
801
804
|
self._redraw()
|
|
802
805
|
|
|
@@ -808,6 +811,8 @@ class ToolCallCard(ExpandableCard):
|
|
|
808
811
|
head.append(f" {self.elapsed:.1f}s", style="dim")
|
|
809
812
|
if self.ran_in:
|
|
810
813
|
head.append(f" · {self.ran_in}", style="dim")
|
|
814
|
+
if self.guard:
|
|
815
|
+
head.append(f" 🛡 {self.guard}", style="yellow" if self.guard_asked else "dim green")
|
|
811
816
|
|
|
812
817
|
cmd = self.input_str.strip()
|
|
813
818
|
cmd_lines = cmd.splitlines() if cmd else []
|
|
@@ -69,14 +69,61 @@ class Verdict:
|
|
|
69
69
|
ms: int = 0
|
|
70
70
|
reason: str = ""
|
|
71
71
|
probabilities: dict = field(default_factory=dict)
|
|
72
|
+
#: Paths it touches outside the workdir: always the person's call.
|
|
73
|
+
outside: list = field(default_factory=list)
|
|
72
74
|
|
|
73
75
|
def describe(self) -> str:
|
|
74
|
-
if self.source
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
if self.source in ("rules", "unavailable"):
|
|
77
|
+
text = self.reason
|
|
78
|
+
else:
|
|
79
|
+
p = f", p_safe {self.p_safe:.2f}" if self.p_safe is not None else ""
|
|
80
|
+
text = f"{self.source}: {self.kind}{p}"
|
|
81
|
+
if self.outside:
|
|
82
|
+
text += f" · outside the workdir: {', '.join(self.outside[:3])}"
|
|
83
|
+
return text
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── Where it reaches ─────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def is_inside(path: str, workdir: str) -> bool:
|
|
89
|
+
"""Whether `path` (absolute, ~, or relative to the workdir) stays in it."""
|
|
90
|
+
if not workdir:
|
|
91
|
+
return True
|
|
92
|
+
from pathlib import Path
|
|
93
|
+
|
|
94
|
+
base = Path(workdir).expanduser().resolve()
|
|
95
|
+
p = Path(os.path.expanduser(path))
|
|
96
|
+
p = (p if p.is_absolute() else base / p).resolve()
|
|
97
|
+
return p == base or p.is_relative_to(base)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def outside_paths(command: str, workdir: str) -> list[str]:
|
|
101
|
+
"""The arguments of `command` that name a place outside the workdir.
|
|
102
|
+
|
|
103
|
+
Path-like words only — absolute, ~, or climbing with .. — so a pattern
|
|
104
|
+
like `grep -rn "a/b"` is not taken for one. A `cd` elsewhere counts."""
|
|
105
|
+
if not workdir:
|
|
106
|
+
return []
|
|
107
|
+
out = []
|
|
108
|
+
for seg in _SEGMENT_SPLIT.split(command or ""):
|
|
109
|
+
try:
|
|
110
|
+
words = shlex.split(seg)
|
|
111
|
+
except ValueError:
|
|
112
|
+
continue
|
|
113
|
+
for w in words[1:] if words else []:
|
|
114
|
+
if w.startswith("-") and "=" in w:
|
|
115
|
+
w = w.split("=", 1)[1]
|
|
116
|
+
if w.startswith("$") or "${" in w or "/$" in w:
|
|
117
|
+
# $HOME, ${X}/y: where it points is not known here — asked.
|
|
118
|
+
out.append(w)
|
|
119
|
+
continue
|
|
120
|
+
if not (w.startswith(("/", "~")) or w == ".." or w.startswith("../") or "/../" in w):
|
|
121
|
+
continue
|
|
122
|
+
if w.startswith("/dev/null"):
|
|
123
|
+
continue
|
|
124
|
+
if not is_inside(w, workdir):
|
|
125
|
+
out.append(w)
|
|
126
|
+
return out
|
|
80
127
|
|
|
81
128
|
|
|
82
129
|
# ── Layer 1: rules ───────────────────────────────────────────────────────────
|
|
@@ -243,9 +290,22 @@ async def available() -> tuple[bool, str]:
|
|
|
243
290
|
|
|
244
291
|
|
|
245
292
|
async def classify(command: str, workdir: str = "") -> Verdict:
|
|
246
|
-
"""What the guard thinks of `command`. Never raises.
|
|
293
|
+
"""What the guard thinks of `command`. Never raises.
|
|
294
|
+
|
|
295
|
+
A command that reaches outside the workdir is never cleared on its own:
|
|
296
|
+
Tev1 still says what it does (reading ~/.aws is "secrets"), and the
|
|
297
|
+
person decides."""
|
|
298
|
+
outside = outside_paths(command, workdir)
|
|
299
|
+
verdict = await _classify(command, skip_rules_clear=bool(outside))
|
|
300
|
+
if outside:
|
|
301
|
+
verdict.safe = False
|
|
302
|
+
verdict.outside = outside
|
|
303
|
+
return verdict
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
async def _classify(command: str, skip_rules_clear: bool = False) -> Verdict:
|
|
247
307
|
found = rules_verdict(command)
|
|
248
|
-
if found is not None:
|
|
308
|
+
if found is not None and not (skip_rules_clear and found.safe):
|
|
249
309
|
return found
|
|
250
310
|
model = model_name()
|
|
251
311
|
if model is None:
|
|
@@ -612,7 +612,10 @@ async def _handle_frame(
|
|
|
612
612
|
dest = exec_.jail_path(str(frame["path"]), wd)
|
|
613
613
|
if dest is not None:
|
|
614
614
|
exec_.before_write(dest)
|
|
615
|
-
elif purpose != "read"
|
|
615
|
+
elif exec_.COMMAND_GATE is not None and (purpose != "read" or frame.get("path")):
|
|
616
|
+
# A read is asked about only for where it reads (the gate lets
|
|
617
|
+
# the workdir through); an older server sends no path, and its
|
|
618
|
+
# reads go through as before.
|
|
616
619
|
try:
|
|
617
620
|
refusal = await exec_.COMMAND_GATE(command, frame)
|
|
618
621
|
except Exception as exc: # the guard failing must not run the command
|
|
@@ -141,3 +141,54 @@ def test_without_a_gate_the_runner_runs_as_before(tmp_path, monkeypatch):
|
|
|
141
141
|
asyncio.run(runner._handle_frame({"type": "execute", "id": "1", "command": "echo ok"}, tmp_path, ws,
|
|
142
142
|
lambda *a: None))
|
|
143
143
|
assert _results(ws) == [(0, "ok\n")]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── Outside the workdir ──────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
@pytest.mark.parametrize("cmd,outside", [
|
|
149
|
+
("cat ~/.ssh/id_rsa", ["~/.ssh/id_rsa"]),
|
|
150
|
+
("ls /", ["/"]),
|
|
151
|
+
("cd .. && ls", [".."]),
|
|
152
|
+
("cat ../other/README.md", ["../other/README.md"]),
|
|
153
|
+
("cat src/a.py", []),
|
|
154
|
+
('grep -rn "a/b" .', []),
|
|
155
|
+
("ls 2>/dev/null", []),
|
|
156
|
+
('find "$HOME" -maxdepth 4 -name .git', ["$HOME"]),
|
|
157
|
+
("cat ${XDG_CONFIG_HOME}/x", ["${XDG_CONFIG_HOME}/x"]),
|
|
158
|
+
])
|
|
159
|
+
def test_paths_outside_the_workdir_are_found(tmp_path, cmd, outside):
|
|
160
|
+
assert guard.outside_paths(cmd, str(tmp_path)) == outside
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_a_read_only_command_outside_the_workdir_is_asked(tmp_path, monkeypatch):
|
|
164
|
+
from virtuai_cli import config
|
|
165
|
+
|
|
166
|
+
config.set_key("command_classifier", "off")
|
|
167
|
+
v = asyncio.run(guard.classify("cat ~/.aws/credentials", str(tmp_path)))
|
|
168
|
+
assert not v.safe and v.outside == ["~/.aws/credentials"]
|
|
169
|
+
assert asyncio.run(guard.classify("cat a.txt", str(tmp_path))).safe
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_the_absolute_workdir_is_inside(tmp_path):
|
|
173
|
+
assert guard.is_inside(str(tmp_path / "src" / "a.py"), str(tmp_path))
|
|
174
|
+
assert not guard.is_inside("/etc/hosts", str(tmp_path))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_a_read_with_a_path_goes_to_the_gate_and_one_without_does_not(tmp_path, monkeypatch):
|
|
178
|
+
seen = []
|
|
179
|
+
|
|
180
|
+
async def gate(command, frame):
|
|
181
|
+
seen.append(frame.get("path"))
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
monkeypatch.setattr(executor, "COMMAND_GATE", gate)
|
|
185
|
+
ws = WS()
|
|
186
|
+
|
|
187
|
+
async def go():
|
|
188
|
+
await runner._handle_frame({"type": "execute", "id": "1", "command": "true", "purpose": "read",
|
|
189
|
+
"path": "/etc/hosts"}, tmp_path, ws, lambda *a: None)
|
|
190
|
+
await runner._handle_frame({"type": "execute", "id": "2", "command": "true", "purpose": "read"},
|
|
191
|
+
tmp_path, ws, lambda *a: None)
|
|
192
|
+
|
|
193
|
+
asyncio.run(go())
|
|
194
|
+
assert seen == ["/etc/hosts"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|