scootcli 0.4.0__tar.gz → 0.5.0__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.
- {scootcli-0.4.0/src/scootcli.egg-info → scootcli-0.5.0}/PKG-INFO +10 -2
- {scootcli-0.4.0 → scootcli-0.5.0}/README.md +9 -1
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/agent.py +67 -1
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/approvals.py +34 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/cli.py +3 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/__init__.py +1 -0
- scootcli-0.5.0/src/scootcli/commands/scope.py +33 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/status.py +1 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/config.py +2 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/headless.py +26 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/prompts.py +1 -1
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/repl.py +9 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/base.py +76 -11
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/edit_file.py +1 -1
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/list_dir.py +1 -1
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/open_editor.py +1 -1
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/read_file.py +1 -1
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/run_shell.py +2 -2
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/write_file.py +1 -1
- {scootcli-0.4.0 → scootcli-0.5.0/src/scootcli.egg-info}/PKG-INFO +10 -2
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli.egg-info/SOURCES.txt +2 -0
- scootcli-0.5.0/tests/test_scope.py +225 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/LICENSE +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/pyproject.toml +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/setup.cfg +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/auth.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/auth.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/hooks.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/model.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/context.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/hooks.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/images.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/keys.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/models.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/panel.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/preferences.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/project.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/providers/base.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/providers/registry.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/providers/router.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/rendering.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/sessions.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/status.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/transport.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/workspace.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/worktree.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_agent.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_anthropic.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_approvals.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_auth.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_config.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_headless.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_hooks.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_images.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_logo.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_models.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_panel.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_preferences.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_providers.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_readiness.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_repl.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_resilience.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_router.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_sessions.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_smoke.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_streaming.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_tools.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_transport_native.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_vision.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_workspace.py +0 -0
- {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_worktree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scootcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A tiny coding agent that goes where you point it
|
|
5
5
|
Author: Sergey Nes
|
|
6
6
|
License-Expression: MIT
|
|
@@ -206,6 +206,8 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
|
|
|
206
206
|
/model list or switch model /approve set mode (always|auto-read|auto-edits|yolo)
|
|
207
207
|
/yolo auto-approve all /worktree isolate work in a git worktree
|
|
208
208
|
/auth provider keys /logo show or toggle the mascot
|
|
209
|
+
/scope where file tools may go /hooks configured hooks and results
|
|
210
|
+
/route how auto picks a model
|
|
209
211
|
/sessions list saved sessions /resume resume a saved session ([id])
|
|
210
212
|
/forget delete session(s) /panel toggle the bottom status bar
|
|
211
213
|
/verbosity feed detail (full|compact|quiet)
|
|
@@ -223,7 +225,11 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
|
223
225
|
### Tools and approvals
|
|
224
226
|
|
|
225
227
|
The agent has eight tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, `open_editor` (hands a file to IntelliJ IDEA's `idea -e` or to VS Code, `SCOOT_EDITOR` picks), and `update_plan` (a progress checklist for multi-step work).
|
|
226
|
-
|
|
228
|
+
Paths are resolved against the workspace root, and `~` works.
|
|
229
|
+
|
|
230
|
+
The workspace is where the agent works, not a wall.
|
|
231
|
+
When a tool needs a file outside it, in `~/.config`, another repo, `/etc`, scoot asks once: allow this path `[a]`, allow that directory for the session `[d]`, allow anywhere for the session `[A]`, skip `[s]`, or quit `[q]`.
|
|
232
|
+
`--scope anywhere`, `SCOOT_SCOPE=anywhere`, or `/scope anywhere` skip the question; `/scope` shows what has been granted.
|
|
227
233
|
|
|
228
234
|
When a call needs approval you can approve once `[a]`, trust that tool for the session `[t]`, approve everything this session `[A]`, edit the arguments `[e]`, skip `[s]`, or quit `[q]`.
|
|
229
235
|
`/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
|
|
@@ -275,6 +281,8 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
|
|
|
275
281
|
```
|
|
276
282
|
|
|
277
283
|
The install script needs only `curl` and Python 3.9+. `SCOOT_VERSION=v0.2.0` pins a release and `SCOOT_INSTALL_DIR` changes the target; read it before you run it, it is sixty lines.
|
|
284
|
+
**Upgrading.** `pipx upgrade scootcli`, or `pip install --upgrade scootcli`, or rerun the curl line, which always fetches the latest release; `scoot --version` shows what you have.
|
|
285
|
+
|
|
278
286
|
Both pipx and the script install into `~/.local/bin`. On a fresh Linux account that directory is added to PATH at login only if it already exists, so after the very first install either open a new login shell or run `pipx ensurepath`; the installer prints the exact line for your shell.
|
|
279
287
|
|
|
280
288
|
Requirements: Python 3.9 or newer on macOS or Linux.
|
|
@@ -181,6 +181,8 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
|
|
|
181
181
|
/model list or switch model /approve set mode (always|auto-read|auto-edits|yolo)
|
|
182
182
|
/yolo auto-approve all /worktree isolate work in a git worktree
|
|
183
183
|
/auth provider keys /logo show or toggle the mascot
|
|
184
|
+
/scope where file tools may go /hooks configured hooks and results
|
|
185
|
+
/route how auto picks a model
|
|
184
186
|
/sessions list saved sessions /resume resume a saved session ([id])
|
|
185
187
|
/forget delete session(s) /panel toggle the bottom status bar
|
|
186
188
|
/verbosity feed detail (full|compact|quiet)
|
|
@@ -198,7 +200,11 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
|
198
200
|
### Tools and approvals
|
|
199
201
|
|
|
200
202
|
The agent has eight tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, `open_editor` (hands a file to IntelliJ IDEA's `idea -e` or to VS Code, `SCOOT_EDITOR` picks), and `update_plan` (a progress checklist for multi-step work).
|
|
201
|
-
|
|
203
|
+
Paths are resolved against the workspace root, and `~` works.
|
|
204
|
+
|
|
205
|
+
The workspace is where the agent works, not a wall.
|
|
206
|
+
When a tool needs a file outside it, in `~/.config`, another repo, `/etc`, scoot asks once: allow this path `[a]`, allow that directory for the session `[d]`, allow anywhere for the session `[A]`, skip `[s]`, or quit `[q]`.
|
|
207
|
+
`--scope anywhere`, `SCOOT_SCOPE=anywhere`, or `/scope anywhere` skip the question; `/scope` shows what has been granted.
|
|
202
208
|
|
|
203
209
|
When a call needs approval you can approve once `[a]`, trust that tool for the session `[t]`, approve everything this session `[A]`, edit the arguments `[e]`, skip `[s]`, or quit `[q]`.
|
|
204
210
|
`/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
|
|
@@ -250,6 +256,8 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
|
|
|
250
256
|
```
|
|
251
257
|
|
|
252
258
|
The install script needs only `curl` and Python 3.9+. `SCOOT_VERSION=v0.2.0` pins a release and `SCOOT_INSTALL_DIR` changes the target; read it before you run it, it is sixty lines.
|
|
259
|
+
**Upgrading.** `pipx upgrade scootcli`, or `pip install --upgrade scootcli`, or rerun the curl line, which always fetches the latest release; `scoot --version` shows what you have.
|
|
260
|
+
|
|
253
261
|
Both pipx and the script install into `~/.local/bin`. On a fresh Linux account that directory is added to PATH at login only if it already exists, so after the very first install either open a new login shell or run `pipx ensurepath`; the installer prints the exact line for your shell.
|
|
254
262
|
|
|
255
263
|
Requirements: Python 3.9 or newer on macOS or Linux.
|
|
@@ -70,6 +70,12 @@ class HeadlessUI:
|
|
|
70
70
|
self.events.append(("approve", tool.name, args))
|
|
71
71
|
return Approval(self.decision, args)
|
|
72
72
|
|
|
73
|
+
scope_decision = "dir" # what this headless UI answers to a scope question (tests override)
|
|
74
|
+
|
|
75
|
+
def approve_scope(self, tool, path, ctx) -> str:
|
|
76
|
+
self.events.append(("scope", tool.name, str(path)))
|
|
77
|
+
return self.scope_decision
|
|
78
|
+
|
|
73
79
|
def auto_approved(self, tool, args) -> None:
|
|
74
80
|
self.events.append(("auto", tool.name, args))
|
|
75
81
|
|
|
@@ -345,7 +351,16 @@ class Agent:
|
|
|
345
351
|
# ── tool execution ───────────────────────────────────────────────────────────
|
|
346
352
|
def _run_tools(self, session, tool_calls, ui, cancel_event, cfg=None) -> Optional[str]:
|
|
347
353
|
cfg = cfg or getattr(session, "config", None) or self.config
|
|
348
|
-
|
|
354
|
+
scope = getattr(session, "scope", None)
|
|
355
|
+
if scope is None:
|
|
356
|
+
from .tools.base import Scope
|
|
357
|
+
|
|
358
|
+
scope = Scope(cfg.root, everything=(getattr(cfg, "scope", "workspace") == "anywhere"))
|
|
359
|
+
try:
|
|
360
|
+
session.scope = scope
|
|
361
|
+
except Exception:
|
|
362
|
+
pass
|
|
363
|
+
ctx = ToolContext(root=cfg.root, config=cfg, cancel_event=cancel_event, scope=scope)
|
|
349
364
|
mode = getattr(session, "approval_mode", "always")
|
|
350
365
|
# Session trust-list: tools the user chose to auto-approve for the rest of the session.
|
|
351
366
|
trusted = getattr(session, "trusted_tools", None)
|
|
@@ -368,6 +383,17 @@ class Agent:
|
|
|
368
383
|
self._append_tool(session, tc_id, f"error: unknown tool '{name}'")
|
|
369
384
|
continue
|
|
370
385
|
|
|
386
|
+
# Paths outside the workspace: ask the user once (this path, its directory, or anywhere).
|
|
387
|
+
outside = self._outside_paths(tool, args, ctx)
|
|
388
|
+
if outside:
|
|
389
|
+
verdict = self._ask_scope(session, tool, outside, ctx, ui)
|
|
390
|
+
if verdict == "abort":
|
|
391
|
+
self._append_tool(session, tc_id, "user aborted the operation")
|
|
392
|
+
return "abort"
|
|
393
|
+
if verdict == "deny":
|
|
394
|
+
self._append_tool(session, tc_id, f"user declined access outside the workspace: {outside[0]}")
|
|
395
|
+
ui.tool_result(name, ToolResult(ok=False, summary="outside the workspace: declined"))
|
|
396
|
+
continue
|
|
371
397
|
# A PreToolUse hook may deny (skip the tool), allow (skip the prompt), or ask (force it).
|
|
372
398
|
hook_action, hook_reason = self._pre_tool_hook(session, tool, args, cancel_event)
|
|
373
399
|
if hook_action == "deny":
|
|
@@ -419,6 +445,46 @@ class Agent:
|
|
|
419
445
|
self._append_tool(session, tc_id, payload or "(no output)")
|
|
420
446
|
return None
|
|
421
447
|
|
|
448
|
+
@staticmethod
|
|
449
|
+
def _outside_paths(tool, args, ctx) -> "list":
|
|
450
|
+
from .tools.base import resolve_path
|
|
451
|
+
|
|
452
|
+
scope = ctx.scope
|
|
453
|
+
found = []
|
|
454
|
+
for raw in tool.paths(args):
|
|
455
|
+
try:
|
|
456
|
+
p = resolve_path(ctx.root, raw)
|
|
457
|
+
except Exception:
|
|
458
|
+
continue
|
|
459
|
+
if scope is None or not scope.allows(p):
|
|
460
|
+
found.append(p)
|
|
461
|
+
return found
|
|
462
|
+
|
|
463
|
+
def _ask_scope(self, session, tool, paths, ctx, ui) -> str:
|
|
464
|
+
"""Ask for the first outside path; grant per the answer. Returns once|dir|all|deny|abort."""
|
|
465
|
+
scope = ctx.scope
|
|
466
|
+
if getattr(session, "config", None) is not None and getattr(session.config, "scope", "") == "anywhere":
|
|
467
|
+
scope.grant_all()
|
|
468
|
+
return "all"
|
|
469
|
+
asker = getattr(ui, "approve_scope", None)
|
|
470
|
+
if asker is None:
|
|
471
|
+
return "deny"
|
|
472
|
+
from .hooks import notify
|
|
473
|
+
|
|
474
|
+
notify(session, "scope", f"{tool.name} wants {paths[0]} outside the workspace")
|
|
475
|
+
verdict = asker(tool, paths[0], ctx)
|
|
476
|
+
if verdict == "once":
|
|
477
|
+
for p in paths:
|
|
478
|
+
scope.grant(p)
|
|
479
|
+
elif verdict == "dir":
|
|
480
|
+
for p in paths:
|
|
481
|
+
scope.grant_dir(p)
|
|
482
|
+
ui.assistant(f"allowing {scope.granted[-1]} for the rest of this session.")
|
|
483
|
+
elif verdict == "all":
|
|
484
|
+
scope.grant_all()
|
|
485
|
+
ui.assistant("allowing access anywhere on this machine for the rest of this session.")
|
|
486
|
+
return verdict
|
|
487
|
+
|
|
422
488
|
@staticmethod
|
|
423
489
|
def _handle_result(session, name: str, result: ToolResult, ui) -> None:
|
|
424
490
|
"""Surface a tool result: render a plan update specially, else the generic result line."""
|
|
@@ -126,6 +126,40 @@ def _edit_args(args: dict) -> dict:
|
|
|
126
126
|
return args
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
SCOPE_CHOICES = ("once", "dir", "all", "deny", "abort")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def request_scope(tool: Tool, path, ctx: ToolContext) -> str:
|
|
133
|
+
"""Ask once about a path outside the workspace. Returns one of ``SCOPE_CHOICES``."""
|
|
134
|
+
from pathlib import Path as _P
|
|
135
|
+
|
|
136
|
+
p = _P(str(path))
|
|
137
|
+
where = p if p.is_dir() else p.parent
|
|
138
|
+
print(color(f" ⌂ {tool.name} wants {p}", "yellow") + color(" (outside the workspace)", "gray"))
|
|
139
|
+
while True:
|
|
140
|
+
print(
|
|
141
|
+
" "
|
|
142
|
+
+ color("allow this path", "green") + " [a] "
|
|
143
|
+
+ color(f"allow {where} this session", "green") + " [d] "
|
|
144
|
+
+ color("allow anywhere this session", "green") + " [A] "
|
|
145
|
+
+ color("skip", "yellow") + " [s] "
|
|
146
|
+
+ color("quit", "red") + " [q] › ",
|
|
147
|
+
end="", flush=True,
|
|
148
|
+
)
|
|
149
|
+
key = read_key()
|
|
150
|
+
print(key)
|
|
151
|
+
if key in ("a", "y", "\r", "\n", ""):
|
|
152
|
+
return "once"
|
|
153
|
+
if key == "d":
|
|
154
|
+
return "dir"
|
|
155
|
+
if key == "A":
|
|
156
|
+
return "all"
|
|
157
|
+
if key == "s":
|
|
158
|
+
return "deny"
|
|
159
|
+
if key == "q":
|
|
160
|
+
return "abort"
|
|
161
|
+
|
|
162
|
+
|
|
129
163
|
def request_approval(tool: Tool, args: dict, ctx: ToolContext) -> Approval:
|
|
130
164
|
"""Show the pending tool call and read the user's decision (with optional arg editing)."""
|
|
131
165
|
while True:
|
|
@@ -59,6 +59,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
59
59
|
help="Don't inject the repo map (git + file tree) into the agent prompt.")
|
|
60
60
|
parser.add_argument("--no-labels", action="store_true",
|
|
61
61
|
help="Hide the role labels/gutters (❯ you / ⏺ scoot) in the REPL transcript.")
|
|
62
|
+
parser.add_argument("--scope", choices=["workspace", "anywhere"],
|
|
63
|
+
help="Where file tools may go: workspace (default; the first access outside it asks once) or anywhere.")
|
|
62
64
|
parser.add_argument("--headless", action="store_true",
|
|
63
65
|
help="Line-delimited JSON on stdin/stdout instead of the REPL (see docs/headless-protocol.md).")
|
|
64
66
|
parser.add_argument("--no-logo", action="store_true",
|
|
@@ -92,6 +94,7 @@ def _config_from_args(args: argparse.Namespace) -> Config:
|
|
|
92
94
|
proxy=args.proxy,
|
|
93
95
|
root=args.root,
|
|
94
96
|
approval="yolo" if (args.yolo or args.yes) else args.approval,
|
|
97
|
+
scope="anywhere" if args.yes else args.scope,
|
|
95
98
|
verbose=True if args.verbose else None,
|
|
96
99
|
stream=False if args.no_stream else None,
|
|
97
100
|
panel=False if args.no_panel else None,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""/scope — where file tools may go: the workspace, plus what you allowed, or anywhere."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..rendering import color, eprint
|
|
6
|
+
from . import register
|
|
7
|
+
from .base import SlashCommand
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _run(session, args: str):
|
|
11
|
+
scope = getattr(session, "scope", None)
|
|
12
|
+
if scope is None:
|
|
13
|
+
eprint(color("scope is not available in this context.", "yellow"))
|
|
14
|
+
return
|
|
15
|
+
want = (args or "").strip().lower()
|
|
16
|
+
if want in ("anywhere", "all", "everywhere"):
|
|
17
|
+
scope.grant_all()
|
|
18
|
+
print(color("file tools may now go anywhere on this machine for this session.", "gray"))
|
|
19
|
+
elif want == "workspace":
|
|
20
|
+
scope.everything = False
|
|
21
|
+
scope.granted.clear()
|
|
22
|
+
print(color("back to the workspace only; the next access outside it will ask.", "gray"))
|
|
23
|
+
elif want:
|
|
24
|
+
scope.grant_dir(want)
|
|
25
|
+
print(color(f"allowed {scope.granted[-1]} for this session.", "gray"))
|
|
26
|
+
print(color("scope: ", "bold") + scope.describe())
|
|
27
|
+
for g in scope.granted:
|
|
28
|
+
print(f" {color('+', 'green')} {g}")
|
|
29
|
+
print(color(" usage: /scope anywhere · /scope workspace · /scope <dir> (also --scope, SCOOT_SCOPE)", "gray"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
register(SlashCommand("scope", "where file tools may go (workspace | anywhere | <dir>)", _run,
|
|
33
|
+
usage="[anywhere|workspace|<dir>]"))
|
|
@@ -25,6 +25,7 @@ def _run(session, args: str):
|
|
|
25
25
|
("env files", ", ".join(getattr(cfg, "env_files", ()) or ()) or "none"),
|
|
26
26
|
("root", str(cfg.root)),
|
|
27
27
|
("approval", session.approval_mode),
|
|
28
|
+
("scope", getattr(session, "scope", None).describe() if getattr(session, "scope", None) else "workspace"),
|
|
28
29
|
("streaming", "on" if getattr(cfg, "stream", True) else "off"),
|
|
29
30
|
("panel", "on" if getattr(session, "status_bar", None) and session.status_bar.enabled
|
|
30
31
|
else "off"),
|
|
@@ -179,6 +179,7 @@ class Config:
|
|
|
179
179
|
vision_model: str = "auto" # provider/model for image descriptions, or "auto" → pick a capable one
|
|
180
180
|
image_max_bytes: int = 4 * 1024 * 1024 # per-image cap (no downscale without a 3rd-party lib)
|
|
181
181
|
editor: str = "idea" # external editor for the open_editor tool: idea | vscode
|
|
182
|
+
scope: str = "workspace" # where file tools may go: workspace (ask once outside it) | anywhere
|
|
182
183
|
env_files: Tuple[str, ...] = () # the .env files that were read, in load order (shown by /status)
|
|
183
184
|
logo: bool = True # mascot in the launch banner + status-bar face (--no-logo / SCOOT_LOGO / /logo)
|
|
184
185
|
emoji: bool = True # 🛴 transcript label; off → ⏺ for terminals without an emoji font (--no-emoji)
|
|
@@ -216,6 +217,7 @@ class Config:
|
|
|
216
217
|
vision_model=get("SCOOT_VISION_MODEL", "auto"),
|
|
217
218
|
image_max_bytes=int(get("SCOOT_IMAGE_MAX_BYTES", str(4 * 1024 * 1024))),
|
|
218
219
|
editor=get("SCOOT_EDITOR", "idea").strip().lower(),
|
|
220
|
+
scope=get("SCOOT_SCOPE", "workspace").strip().lower(),
|
|
219
221
|
logo=_resolve_logo_setting(get("SCOOT_LOGO")),
|
|
220
222
|
emoji=_as_bool(get("SCOOT_EMOJI", "true")),
|
|
221
223
|
)
|
|
@@ -136,6 +136,32 @@ class HeadlessUI:
|
|
|
136
136
|
auto_approved=False, decision=str(msg.get("decision")).lower())
|
|
137
137
|
return Approval(decision, new_args)
|
|
138
138
|
|
|
139
|
+
def approve_scope(self, tool, path, ctx) -> str:
|
|
140
|
+
req = self._call_id()
|
|
141
|
+
self.writer.emit("scope_request", id=req, name=tool.name, path=str(path),
|
|
142
|
+
options=["allow_once", "allow_dir", "allow_all", "deny", "abort"], timeout_s=self.timeout)
|
|
143
|
+
mapping = {"allow_once": "once", "allow_dir": "dir", "allow_all": "all", "deny": "deny", "abort": "abort"}
|
|
144
|
+
deadline = time.time() + self.timeout
|
|
145
|
+
while True:
|
|
146
|
+
remaining = deadline - time.time()
|
|
147
|
+
if remaining <= 0:
|
|
148
|
+
self.writer.emit("notice", message=f"scope request {req} timed out after {self.timeout:g}s; denied")
|
|
149
|
+
return "deny"
|
|
150
|
+
if self.cancel_event is not None and self.cancel_event.is_set():
|
|
151
|
+
return "abort"
|
|
152
|
+
try:
|
|
153
|
+
msg = self.answers.get(timeout=min(0.2, remaining))
|
|
154
|
+
except queue.Empty:
|
|
155
|
+
continue
|
|
156
|
+
if msg.get("id") not in (None, "", req):
|
|
157
|
+
self.writer.emit("error", message=f"answer for unknown request {msg.get('id')}", kind="protocol")
|
|
158
|
+
continue
|
|
159
|
+
verdict = mapping.get(str(msg.get("decision", "")).lower())
|
|
160
|
+
if verdict is None:
|
|
161
|
+
self.writer.emit("error", message=f"unknown scope decision {msg.get('decision')!r}; expected one of {list(mapping)}", kind="protocol")
|
|
162
|
+
continue
|
|
163
|
+
return verdict
|
|
164
|
+
|
|
139
165
|
def plan(self, plan) -> None:
|
|
140
166
|
self.writer.emit("plan", steps=plan)
|
|
141
167
|
|
|
@@ -23,7 +23,7 @@ You run inside the user's workspace and act through tools. Be concise, correct,
|
|
|
23
23
|
ENVIRONMENT
|
|
24
24
|
- Working directory (root): {{workspace_root}}
|
|
25
25
|
- OS: {{os}} Model: {{model}}
|
|
26
|
-
-
|
|
26
|
+
- The root is your workspace, not a wall: when the task needs files elsewhere on this machine (~/.config, another repo, /etc), use them with absolute paths (~ is fine). The first access outside the workspace asks the user once; never tell the user you are not allowed to leave the workspace, and never hand them a script to run in your place because of it.
|
|
27
27
|
|
|
28
28
|
WORKSPACE (auto-generated map of the current repo — use it to orient before listing/reading files)
|
|
29
29
|
{{workspace}}
|
|
@@ -79,6 +79,9 @@ class ReplSession:
|
|
|
79
79
|
self.mascot_state = "idle" # drives the mascot's eyes in the status bar: idle | thinking | stopped
|
|
80
80
|
self.usage_by_model: dict = {} # qualified model -> {"prompt": n, "completion": n, "calls": n}
|
|
81
81
|
self.provider_ready = True # False until a provider can take a request (see refresh_readiness)
|
|
82
|
+
from .tools.base import Scope
|
|
83
|
+
|
|
84
|
+
self.scope = Scope(config.root, everything=(getattr(config, "scope", "workspace") == "anywhere"))
|
|
82
85
|
self.setup_message = "" # what to do about it, shown in the banner and after failures
|
|
83
86
|
self.route_reason = "" # why the router picked the active model (auto only)
|
|
84
87
|
# Persistence (auto-save each turn; resume via --continue/--resume).
|
|
@@ -412,6 +415,12 @@ class ReplUI:
|
|
|
412
415
|
self._commit_line() # an approval prompt is interactive; don't let it clobber a transient
|
|
413
416
|
return request_approval(tool, args, ctx)
|
|
414
417
|
|
|
418
|
+
def approve_scope(self, tool, path, ctx) -> str:
|
|
419
|
+
self._commit_line()
|
|
420
|
+
from .approvals import request_scope
|
|
421
|
+
|
|
422
|
+
return request_scope(tool, path, ctx)
|
|
423
|
+
|
|
415
424
|
def auto_approved(self, tool, args) -> None:
|
|
416
425
|
import json as _json
|
|
417
426
|
|
|
@@ -56,6 +56,53 @@ class ToolContext:
|
|
|
56
56
|
root: Path
|
|
57
57
|
config: Any = None
|
|
58
58
|
cancel_event: Optional[threading.Event] = None
|
|
59
|
+
scope: "Optional[Scope]" = None # where file tools may go beyond the workspace root
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Scope:
|
|
63
|
+
"""Where file tools may operate: the workspace root plus what the user granted this session.
|
|
64
|
+
|
|
65
|
+
The workspace is always allowed. The first access elsewhere asks the user once, who can allow
|
|
66
|
+
that path, its directory for the session, or anywhere for the session (``everything``).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, root, everything: bool = False):
|
|
70
|
+
self.root = Path(root).resolve()
|
|
71
|
+
self.granted: List[Path] = []
|
|
72
|
+
self.everything = everything
|
|
73
|
+
|
|
74
|
+
def allows(self, path) -> bool:
|
|
75
|
+
p = Path(path).expanduser().resolve()
|
|
76
|
+
if self.everything or _inside(p, self.root):
|
|
77
|
+
return True
|
|
78
|
+
return any(_inside(p, g) for g in self.granted)
|
|
79
|
+
|
|
80
|
+
def grant(self, path) -> Path:
|
|
81
|
+
p = Path(path).expanduser().resolve()
|
|
82
|
+
if p not in self.granted:
|
|
83
|
+
self.granted.append(p)
|
|
84
|
+
return p
|
|
85
|
+
|
|
86
|
+
def grant_dir(self, path) -> Path:
|
|
87
|
+
p = Path(path).expanduser().resolve()
|
|
88
|
+
return self.grant(p if p.is_dir() else p.parent)
|
|
89
|
+
|
|
90
|
+
def grant_all(self) -> None:
|
|
91
|
+
self.everything = True
|
|
92
|
+
|
|
93
|
+
def describe(self) -> str:
|
|
94
|
+
if self.everything:
|
|
95
|
+
return "anywhere on this machine (for this session)"
|
|
96
|
+
extra = f" + {len(self.granted)} granted" if self.granted else ""
|
|
97
|
+
return f"workspace {self.root}{extra}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _inside(path: Path, parent: Path) -> bool:
|
|
101
|
+
try:
|
|
102
|
+
path.relative_to(parent)
|
|
103
|
+
return True
|
|
104
|
+
except ValueError:
|
|
105
|
+
return False
|
|
59
106
|
|
|
60
107
|
|
|
61
108
|
@dataclass
|
|
@@ -89,6 +136,13 @@ class Tool:
|
|
|
89
136
|
"""Optional rich preview shown at approval time (e.g. a diff). ``None`` = args-only."""
|
|
90
137
|
return None
|
|
91
138
|
|
|
139
|
+
def paths(self, args: dict) -> "list[str]":
|
|
140
|
+
"""The filesystem paths a call touches, for the scope check before it runs.
|
|
141
|
+
|
|
142
|
+
Default: the ``path`` and ``cwd`` arguments. A tool with other path arguments overrides this.
|
|
143
|
+
"""
|
|
144
|
+
return [str(args[k]) for k in ("path", "cwd") if isinstance(args.get(k), str) and args.get(k)]
|
|
145
|
+
|
|
92
146
|
def schema(self) -> dict:
|
|
93
147
|
"""OpenAI-style function schema for the ``tools`` request param."""
|
|
94
148
|
return {
|
|
@@ -102,20 +156,31 @@ class Tool:
|
|
|
102
156
|
|
|
103
157
|
|
|
104
158
|
# ── Sandboxing ─────────────────────────────────────────────────────────────────
|
|
105
|
-
def
|
|
106
|
-
"""
|
|
159
|
+
def resolve_path(root: Path, raw: str) -> Path:
|
|
160
|
+
"""``raw`` as an absolute path: ``~`` expanded, relative paths taken from ``root``."""
|
|
107
161
|
if raw is None:
|
|
108
162
|
raise ToolError("missing 'path'")
|
|
109
|
-
|
|
110
|
-
p = Path(raw)
|
|
163
|
+
p = Path(str(raw)).expanduser()
|
|
111
164
|
if not p.is_absolute():
|
|
112
|
-
p = root / p
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
165
|
+
p = Path(root).resolve() / p
|
|
166
|
+
return p.resolve()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def safe_path(root: Path, raw: str, scope: "Optional[Scope]" = None) -> Path:
|
|
170
|
+
"""Resolve ``raw`` and check it is inside the workspace or granted by ``scope``.
|
|
171
|
+
|
|
172
|
+
Raises :class:`PathEscapeError` (carrying ``.path``) otherwise; the agent turns that into a
|
|
173
|
+
question to the user rather than a refusal.
|
|
174
|
+
"""
|
|
175
|
+
p = resolve_path(root, raw)
|
|
176
|
+
if scope is not None:
|
|
177
|
+
if scope.allows(p):
|
|
178
|
+
return p
|
|
179
|
+
elif _inside(p, Path(root).resolve()):
|
|
180
|
+
return p
|
|
181
|
+
exc = PathEscapeError(f"outside the workspace: {p} (the user can allow it)")
|
|
182
|
+
exc.path = p
|
|
183
|
+
raise exc
|
|
119
184
|
|
|
120
185
|
|
|
121
186
|
# ── Truncation ─────────────────────────────────────────────────────────────────
|
|
@@ -25,7 +25,7 @@ class EditFile(Tool):
|
|
|
25
25
|
|
|
26
26
|
def _compute(self, args, ctx):
|
|
27
27
|
"""Return (path, old_text, new_text). Raises ToolError on any problem."""
|
|
28
|
-
path = safe_path(ctx.root, args.get("path"))
|
|
28
|
+
path = safe_path(ctx.root, args.get("path"), ctx.scope)
|
|
29
29
|
if not path.exists() or not path.is_file():
|
|
30
30
|
raise ToolError(f"no such file: {args.get('path')}")
|
|
31
31
|
old_string = args.get("old_string")
|
|
@@ -23,7 +23,7 @@ class ListDir(Tool):
|
|
|
23
23
|
def run(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
24
24
|
raw = args.get("path") or "."
|
|
25
25
|
try:
|
|
26
|
-
path = safe_path(ctx.root, raw)
|
|
26
|
+
path = safe_path(ctx.root, raw, ctx.scope)
|
|
27
27
|
except ToolError as exc:
|
|
28
28
|
return ToolResult.fail(str(exc))
|
|
29
29
|
if not path.exists():
|
|
@@ -88,7 +88,7 @@ class OpenEditor(Tool):
|
|
|
88
88
|
if not raw:
|
|
89
89
|
return ToolResult.fail("missing 'path'")
|
|
90
90
|
try:
|
|
91
|
-
path = safe_path(ctx.root, raw)
|
|
91
|
+
path = safe_path(ctx.root, raw, ctx.scope)
|
|
92
92
|
except ToolError as exc:
|
|
93
93
|
return ToolResult.fail(str(exc))
|
|
94
94
|
if not path.exists():
|
|
@@ -25,7 +25,7 @@ class ReadFile(Tool):
|
|
|
25
25
|
|
|
26
26
|
def run(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
27
27
|
try:
|
|
28
|
-
path = safe_path(ctx.root, args.get("path"))
|
|
28
|
+
path = safe_path(ctx.root, args.get("path"), ctx.scope)
|
|
29
29
|
except ToolError as exc:
|
|
30
30
|
return ToolResult.fail(str(exc))
|
|
31
31
|
if not path.exists():
|
|
@@ -28,7 +28,7 @@ class RunShell(Tool):
|
|
|
28
28
|
"type": "object",
|
|
29
29
|
"properties": {
|
|
30
30
|
"command": {"type": "string", "description": "The shell command to run."},
|
|
31
|
-
"cwd": {"type": "string", "description": "Working
|
|
31
|
+
"cwd": {"type": "string", "description": "Working directory (optional): relative to the workspace, or absolute."},
|
|
32
32
|
},
|
|
33
33
|
"required": ["command"],
|
|
34
34
|
}
|
|
@@ -45,7 +45,7 @@ class RunShell(Tool):
|
|
|
45
45
|
cwd = ctx.root
|
|
46
46
|
if args.get("cwd"):
|
|
47
47
|
try:
|
|
48
|
-
cwd = safe_path(ctx.root, args["cwd"])
|
|
48
|
+
cwd = safe_path(ctx.root, args["cwd"], ctx.scope)
|
|
49
49
|
except ToolError as exc:
|
|
50
50
|
return ToolResult.fail(str(exc))
|
|
51
51
|
if not cwd.is_dir():
|
|
@@ -21,7 +21,7 @@ class WriteFile(Tool):
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
def _old_new(self, args, ctx):
|
|
24
|
-
path = safe_path(ctx.root, args.get("path"))
|
|
24
|
+
path = safe_path(ctx.root, args.get("path"), ctx.scope)
|
|
25
25
|
old = path.read_text("utf-8", "replace") if path.exists() and path.is_file() else ""
|
|
26
26
|
new = args.get("content") or ""
|
|
27
27
|
return path, old, new
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scootcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A tiny coding agent that goes where you point it
|
|
5
5
|
Author: Sergey Nes
|
|
6
6
|
License-Expression: MIT
|
|
@@ -206,6 +206,8 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
|
|
|
206
206
|
/model list or switch model /approve set mode (always|auto-read|auto-edits|yolo)
|
|
207
207
|
/yolo auto-approve all /worktree isolate work in a git worktree
|
|
208
208
|
/auth provider keys /logo show or toggle the mascot
|
|
209
|
+
/scope where file tools may go /hooks configured hooks and results
|
|
210
|
+
/route how auto picks a model
|
|
209
211
|
/sessions list saved sessions /resume resume a saved session ([id])
|
|
210
212
|
/forget delete session(s) /panel toggle the bottom status bar
|
|
211
213
|
/verbosity feed detail (full|compact|quiet)
|
|
@@ -223,7 +225,11 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
|
223
225
|
### Tools and approvals
|
|
224
226
|
|
|
225
227
|
The agent has eight tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, `open_editor` (hands a file to IntelliJ IDEA's `idea -e` or to VS Code, `SCOOT_EDITOR` picks), and `update_plan` (a progress checklist for multi-step work).
|
|
226
|
-
|
|
228
|
+
Paths are resolved against the workspace root, and `~` works.
|
|
229
|
+
|
|
230
|
+
The workspace is where the agent works, not a wall.
|
|
231
|
+
When a tool needs a file outside it, in `~/.config`, another repo, `/etc`, scoot asks once: allow this path `[a]`, allow that directory for the session `[d]`, allow anywhere for the session `[A]`, skip `[s]`, or quit `[q]`.
|
|
232
|
+
`--scope anywhere`, `SCOOT_SCOPE=anywhere`, or `/scope anywhere` skip the question; `/scope` shows what has been granted.
|
|
227
233
|
|
|
228
234
|
When a call needs approval you can approve once `[a]`, trust that tool for the session `[t]`, approve everything this session `[A]`, edit the arguments `[e]`, skip `[s]`, or quit `[q]`.
|
|
229
235
|
`/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
|
|
@@ -275,6 +281,8 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
|
|
|
275
281
|
```
|
|
276
282
|
|
|
277
283
|
The install script needs only `curl` and Python 3.9+. `SCOOT_VERSION=v0.2.0` pins a release and `SCOOT_INSTALL_DIR` changes the target; read it before you run it, it is sixty lines.
|
|
284
|
+
**Upgrading.** `pipx upgrade scootcli`, or `pip install --upgrade scootcli`, or rerun the curl line, which always fetches the latest release; `scoot --version` shows what you have.
|
|
285
|
+
|
|
278
286
|
Both pipx and the script install into `~/.local/bin`. On a fresh Linux account that directory is added to PATH at login only if it already exists, so after the very first install either open a new login shell or run `pipx ensurepath`; the installer prints the exact line for your shell.
|
|
279
287
|
|
|
280
288
|
Requirements: Python 3.9 or newer on macOS or Linux.
|
|
@@ -56,6 +56,7 @@ src/scootcli/commands/reset.py
|
|
|
56
56
|
src/scootcli/commands/resume.py
|
|
57
57
|
src/scootcli/commands/route.py
|
|
58
58
|
src/scootcli/commands/save.py
|
|
59
|
+
src/scootcli/commands/scope.py
|
|
59
60
|
src/scootcli/commands/sessions.py
|
|
60
61
|
src/scootcli/commands/status.py
|
|
61
62
|
src/scootcli/commands/verbosity.py
|
|
@@ -97,6 +98,7 @@ tests/test_readiness.py
|
|
|
97
98
|
tests/test_repl.py
|
|
98
99
|
tests/test_resilience.py
|
|
99
100
|
tests/test_router.py
|
|
101
|
+
tests/test_scope.py
|
|
100
102
|
tests/test_sessions.py
|
|
101
103
|
tests/test_smoke.py
|
|
102
104
|
tests/test_streaming.py
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Scope: the workspace is the default, not a wall. Outside paths ask once and can be granted."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from scootcli import tools
|
|
10
|
+
from scootcli.agent import Agent, HeadlessUI
|
|
11
|
+
from scootcli.config import Config
|
|
12
|
+
from scootcli.providers import ChatResult
|
|
13
|
+
from scootcli.tools.base import PathEscapeError, Scope, ToolContext, resolve_path, safe_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_scope_allows_workspace_grants_and_everything(tmp_path):
|
|
17
|
+
ws = tmp_path / "ws"
|
|
18
|
+
ws.mkdir()
|
|
19
|
+
other = tmp_path / "other"
|
|
20
|
+
other.mkdir()
|
|
21
|
+
s = Scope(ws)
|
|
22
|
+
assert s.allows(ws / "a.txt") and not s.allows(other / "b.txt")
|
|
23
|
+
s.grant(other / "b.txt")
|
|
24
|
+
assert s.allows(other / "b.txt") and not s.allows(other / "c.txt")
|
|
25
|
+
s.grant_dir(other / "c.txt")
|
|
26
|
+
assert s.allows(other / "c.txt") and s.allows(other / "deep" / "d.txt")
|
|
27
|
+
assert "granted" in s.describe()
|
|
28
|
+
s.grant_all()
|
|
29
|
+
assert s.allows("/etc/hosts") and "anywhere" in s.describe()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_safe_path_expands_home_and_honours_scope(tmp_path, monkeypatch):
|
|
33
|
+
ws = tmp_path / "ws"
|
|
34
|
+
ws.mkdir()
|
|
35
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
36
|
+
assert resolve_path(ws, "~/x.txt") == (tmp_path / "x.txt").resolve()
|
|
37
|
+
assert safe_path(ws, "inside.txt") == (ws / "inside.txt").resolve()
|
|
38
|
+
try:
|
|
39
|
+
safe_path(ws, "~/x.txt")
|
|
40
|
+
assert False
|
|
41
|
+
except PathEscapeError as exc:
|
|
42
|
+
assert exc.path == (tmp_path / "x.txt").resolve() and "user can allow" in str(exc)
|
|
43
|
+
scope = Scope(ws)
|
|
44
|
+
scope.grant_dir(tmp_path)
|
|
45
|
+
assert safe_path(ws, "~/x.txt", scope) == (tmp_path / "x.txt").resolve()
|
|
46
|
+
assert safe_path(ws, "../x.txt", Scope(ws, everything=True)) == (tmp_path / "x.txt").resolve()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _toolcall(name, args, cid):
|
|
50
|
+
return ChatResult(content="", model="m", tool_calls=[{"id": cid, "type": "function", "function": {"name": name, "arguments": json.dumps(args)}}])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _Provider:
|
|
54
|
+
def __init__(self, results):
|
|
55
|
+
self.results, self.i = results, 0
|
|
56
|
+
|
|
57
|
+
def chat(self, *a, **k):
|
|
58
|
+
r = self.results[min(self.i, len(self.results) - 1)]
|
|
59
|
+
self.i += 1
|
|
60
|
+
return r
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _S:
|
|
64
|
+
def __init__(self, root, **cfg):
|
|
65
|
+
self.messages, self.model, self.active_model = [], "m", "m"
|
|
66
|
+
self.approval_mode = "yolo"
|
|
67
|
+
self.bad_models, self.trusted_tools = set(), set()
|
|
68
|
+
self.config = Config().override(root=str(root), stream=False, workspace_context=False, **cfg)
|
|
69
|
+
self.last_usage = {}
|
|
70
|
+
|
|
71
|
+
def resolved_model(self):
|
|
72
|
+
return "m"
|
|
73
|
+
|
|
74
|
+
def available_models(self):
|
|
75
|
+
return ["m"]
|
|
76
|
+
|
|
77
|
+
def account(self, usage, model=""):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _run(root, results, decision="dir", **cfg):
|
|
82
|
+
tools.load_builtins()
|
|
83
|
+
s = _S(root, **cfg)
|
|
84
|
+
ui = HeadlessUI()
|
|
85
|
+
ui.scope_decision = decision
|
|
86
|
+
out = Agent(s.config, _Provider(results)).run_turn(s, ui)
|
|
87
|
+
return s, ui, out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_agent_asks_once_then_reads_outside_the_workspace(tmp_path):
|
|
91
|
+
ws = tmp_path / "ws"
|
|
92
|
+
ws.mkdir()
|
|
93
|
+
secret = tmp_path / "elsewhere" / "notes.txt"
|
|
94
|
+
secret.parent.mkdir()
|
|
95
|
+
secret.write_text("outside content")
|
|
96
|
+
results = [_toolcall("read_file", {"path": str(secret)}, "c1"),
|
|
97
|
+
_toolcall("read_file", {"path": str(secret.parent / "other.txt")}, "c2"),
|
|
98
|
+
ChatResult(content="done", model="m")]
|
|
99
|
+
(secret.parent / "other.txt").write_text("second file")
|
|
100
|
+
s, ui, out = _run(ws, results, decision="dir")
|
|
101
|
+
scope_events = [e for e in ui.events if e[0] == "scope"]
|
|
102
|
+
assert len(scope_events) == 1 and scope_events[0][2] == str(secret) # asked once, the dir was granted
|
|
103
|
+
tool_msgs = [m["content"] for m in s.messages if m["role"] == "tool"]
|
|
104
|
+
assert "outside content" in tool_msgs[0] and "second file" in tool_msgs[1]
|
|
105
|
+
assert s.scope.granted == [secret.parent.resolve()]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_agent_deny_abort_once_and_all(tmp_path):
|
|
109
|
+
ws = tmp_path / "ws"
|
|
110
|
+
ws.mkdir()
|
|
111
|
+
target = tmp_path / "x" / "f.txt"
|
|
112
|
+
target.parent.mkdir()
|
|
113
|
+
target.write_text("x")
|
|
114
|
+
call = [_toolcall("read_file", {"path": str(target)}, "c1"), ChatResult(content="done", model="m")]
|
|
115
|
+
s, ui, out = _run(ws, call, decision="deny")
|
|
116
|
+
assert out.status == "done"
|
|
117
|
+
assert [m["content"] for m in s.messages if m["role"] == "tool"][0].startswith("user declined access outside the workspace")
|
|
118
|
+
s, ui, out = _run(ws, call, decision="abort")
|
|
119
|
+
assert out.status == "aborted"
|
|
120
|
+
s, ui, out = _run(ws, call, decision="once")
|
|
121
|
+
assert s.scope.granted == [target.resolve()] and not s.scope.allows(target.parent / "g.txt")
|
|
122
|
+
s, ui, out = _run(ws, call, decision="all")
|
|
123
|
+
assert s.scope.everything and s.scope.allows("/etc/hosts")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_scope_anywhere_never_asks(tmp_path):
|
|
127
|
+
ws = tmp_path / "ws"
|
|
128
|
+
ws.mkdir()
|
|
129
|
+
target = tmp_path / "y.txt"
|
|
130
|
+
target.write_text("y")
|
|
131
|
+
call = [_toolcall("read_file", {"path": str(target)}, "c1"), ChatResult(content="done", model="m")]
|
|
132
|
+
s, ui, out = _run(ws, call, decision="deny", scope="anywhere")
|
|
133
|
+
assert not any(e[0] == "scope" for e in ui.events)
|
|
134
|
+
assert "y" in [m["content"] for m in s.messages if m["role"] == "tool"][0]
|
|
135
|
+
# write and shell cwd outside the workspace go through the same gate
|
|
136
|
+
out_file = tmp_path / "z" / "new.txt"
|
|
137
|
+
calls = [_toolcall("write_file", {"path": str(out_file), "content": "hi"}, "c1"),
|
|
138
|
+
_toolcall("run_shell", {"command": "pwd", "cwd": str(tmp_path / "z")}, "c2"),
|
|
139
|
+
ChatResult(content="done", model="m")]
|
|
140
|
+
s, ui, out = _run(ws, calls, decision="dir")
|
|
141
|
+
assert out_file.read_text() == "hi" and len([e for e in ui.events if e[0] == "scope"]) == 1
|
|
142
|
+
assert str((tmp_path / "z").resolve()) in [m["content"] for m in s.messages if m["role"] == "tool"][1]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_yes_flag_implies_scope_anywhere_and_env_setting(monkeypatch, tmp_path):
|
|
146
|
+
from scootcli.cli import _build_parser, _config_from_args
|
|
147
|
+
|
|
148
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
|
|
149
|
+
assert _config_from_args(_build_parser().parse_args(["--yes", "x"])).scope == "anywhere"
|
|
150
|
+
assert _config_from_args(_build_parser().parse_args(["--scope", "anywhere", "x"])).scope == "anywhere"
|
|
151
|
+
assert _config_from_args(_build_parser().parse_args(["x"])).scope == "workspace"
|
|
152
|
+
monkeypatch.setenv("SCOOT_SCOPE", "anywhere")
|
|
153
|
+
assert Config.load(cwd=tmp_path).scope == "anywhere"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_prompt_no_longer_claims_a_wall():
|
|
157
|
+
from scootcli.prompts import AGENT_SYSTEM_PROMPT
|
|
158
|
+
|
|
159
|
+
assert "must stay inside the root" not in AGENT_SYSTEM_PROMPT
|
|
160
|
+
assert "asks the user once" in AGENT_SYSTEM_PROMPT
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_headless_scope_request_round_trip(tmp_path):
|
|
164
|
+
import queue
|
|
165
|
+
import threading
|
|
166
|
+
import time
|
|
167
|
+
import io
|
|
168
|
+
|
|
169
|
+
from scootcli.headless import Headless
|
|
170
|
+
|
|
171
|
+
ws = tmp_path / "ws"
|
|
172
|
+
ws.mkdir()
|
|
173
|
+
target = tmp_path / "far.txt"
|
|
174
|
+
target.write_text("far away")
|
|
175
|
+
provider = _Provider([_toolcall("read_file", {"path": str(target)}, "c1"), ChatResult(content="ok", model="m")])
|
|
176
|
+
s = _S(ws)
|
|
177
|
+
s.id, s.provider, s.resumed, s.autosave = "hl", provider, False, lambda: None
|
|
178
|
+
|
|
179
|
+
class _In:
|
|
180
|
+
def __init__(self):
|
|
181
|
+
self.q = queue.Queue()
|
|
182
|
+
|
|
183
|
+
def __iter__(self):
|
|
184
|
+
while True:
|
|
185
|
+
l = self.q.get()
|
|
186
|
+
if l is None:
|
|
187
|
+
return
|
|
188
|
+
yield l + "\n"
|
|
189
|
+
|
|
190
|
+
class _Out:
|
|
191
|
+
def __init__(self):
|
|
192
|
+
self.lines, self.lock = [], threading.Lock()
|
|
193
|
+
|
|
194
|
+
def write(self, t):
|
|
195
|
+
with self.lock:
|
|
196
|
+
self.lines.append(t)
|
|
197
|
+
|
|
198
|
+
def flush(self):
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
def events(self):
|
|
202
|
+
with self.lock:
|
|
203
|
+
return [json.loads(l) for l in "".join(self.lines).splitlines() if l.strip()]
|
|
204
|
+
|
|
205
|
+
inp, out = _In(), _Out()
|
|
206
|
+
h = Headless(s, Agent(s.config, provider), stdin=inp, stdout=out, approval_timeout_s=5, heartbeat_s=0)
|
|
207
|
+
threading.Thread(target=h.run, daemon=True).start()
|
|
208
|
+
|
|
209
|
+
def wait(t):
|
|
210
|
+
end = time.time() + 5
|
|
211
|
+
while time.time() < end:
|
|
212
|
+
for e in out.events():
|
|
213
|
+
if e["type"] == t:
|
|
214
|
+
return e
|
|
215
|
+
time.sleep(0.02)
|
|
216
|
+
raise AssertionError(t)
|
|
217
|
+
|
|
218
|
+
wait("ready")
|
|
219
|
+
inp.q.put(json.dumps({"type": "prompt", "text": "read far.txt"}))
|
|
220
|
+
req = wait("scope_request")
|
|
221
|
+
assert req["name"] == "read_file" and req["path"] == str(target.resolve()) and "allow_dir" in req["options"]
|
|
222
|
+
inp.q.put(json.dumps({"type": "approve", "id": req["id"], "decision": "allow_dir"}))
|
|
223
|
+
end = wait("turn_end")
|
|
224
|
+
assert end["status"] == "done" and "far away" in wait("tool_result")["content"]
|
|
225
|
+
inp.q.put(None)
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|