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.
Files changed (51) hide show
  1. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/PKG-INFO +1 -1
  2. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/pyproject.toml +1 -1
  3. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/__init__.py +1 -1
  4. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/look.py +8 -0
  5. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/steps.py +25 -11
  6. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/tui.py +85 -11
  7. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/widgets.py +5 -0
  8. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/command_guard.py +68 -8
  9. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/runner.py +4 -1
  10. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
  11. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_command_guard.py +51 -0
  12. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/README.md +0 -0
  13. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/setup.cfg +0 -0
  14. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/browser.py +0 -0
  15. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/__init__.py +0 -0
  16. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/agents.py +0 -0
  17. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/ask.py +0 -0
  18. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/clipboard.py +0 -0
  19. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/command.py +0 -0
  20. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/devtools.py +0 -0
  21. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/highlight.py +0 -0
  22. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/history.py +0 -0
  23. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/journal.py +0 -0
  24. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/panels.py +0 -0
  25. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/review.py +0 -0
  26. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/chat/sse.py +0 -0
  27. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/config.py +0 -0
  28. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/executor.py +0 -0
  29. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/guard_setup.py +0 -0
  30. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/main.py +0 -0
  31. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/ollama_state.py +0 -0
  32. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/omlx_state.py +0 -0
  33. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/opencode.py +0 -0
  34. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/pull.py +0 -0
  35. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli/security.py +0 -0
  36. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
  37. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  38. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  39. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli.egg-info/requires.txt +0 -0
  40. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/src/virtuai_cli.egg-info/top_level.txt +0 -0
  41. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_browser.py +0 -0
  42. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_frame_concurrency.py +0 -0
  43. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_guard_setup.py +0 -0
  44. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_ollama_options.py +0 -0
  45. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_ollama_state.py +0 -0
  46. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_omlx.py +0 -0
  47. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_process_group_kill.py +0 -0
  48. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_proxy_env.py +0 -0
  49. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_pull_fallback.py +0 -0
  50. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_run_single_instance.py +0 -0
  51. {virtuai_cli-1.0.7 → virtuai_cli-1.0.9}/tests/test_session_isolation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 1.0.7
3
+ Version: 1.0.9
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "virtuai-cli"
7
- version = "1.0.7"
7
+ version = "1.0.9"
8
8
  description = "Run VirtuAI deep agents on your local machine"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,2 +1,2 @@
1
1
  """VirtuAI local CLI."""
2
- __version__ = "1.0.7"
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
- self.border_title = f"⚠ {T['guard_title']}"
466
- self.result: "asyncio.Future[bool]" = asyncio.get_event_loop().create_future()
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
- yield OptionList(Option(row("1", T["guard_allow"]), id="allow"),
483
- Option(row("2", T["guard_deny"]), id="deny"))
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._answer(index == 0)
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 == "allow")
506
+ self._answer(event.option.id or "deny")
496
507
 
497
- def _answer(self, allowed: bool, note: str = "") -> None:
508
+ def _answer(self, choice: str, note: str = "") -> None:
498
509
  if self.result.done():
499
510
  return
500
- self.result.set_result(allowed)
501
- self.settle(note or (T["guard_allowed"] if allowed else T["guard_denied"]))
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(False)
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) -> tuple[bool, str]:
1927
- """(run it?, what the guard thought). Asks you when it is not clearly safe."""
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
- prompt = CommandGuardPrompt(command, verdict.describe())
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
- allowed = await asyncio.wait_for(asyncio.shield(prompt.result), timeout)
1954
+ choice = await asyncio.wait_for(asyncio.shield(prompt.result), timeout)
1945
1955
  except asyncio.TimeoutError:
1946
1956
  prompt.expire()
1947
- allowed = False
1948
- if not allowed:
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") or ""
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 == "rules":
75
- return self.reason
76
- if self.source == "unavailable":
77
- return self.reason
78
- p = f", p_safe {self.p_safe:.2f}" if self.p_safe is not None else ""
79
- return f"{self.source}: {self.kind}{p}"
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" and exec_.COMMAND_GATE is not None:
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 1.0.7
3
+ Version: 1.0.9
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
@@ -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