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.
Files changed (111) hide show
  1. {scootcli-0.4.0/src/scootcli.egg-info → scootcli-0.5.0}/PKG-INFO +10 -2
  2. {scootcli-0.4.0 → scootcli-0.5.0}/README.md +9 -1
  3. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/agent.py +67 -1
  5. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/approvals.py +34 -0
  6. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/cli.py +3 -0
  7. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/__init__.py +1 -0
  8. scootcli-0.5.0/src/scootcli/commands/scope.py +33 -0
  9. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/status.py +1 -0
  10. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/config.py +2 -0
  11. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/headless.py +26 -0
  12. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/prompts.py +1 -1
  13. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/repl.py +9 -0
  14. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/base.py +76 -11
  15. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/edit_file.py +1 -1
  16. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/list_dir.py +1 -1
  17. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/open_editor.py +1 -1
  18. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/read_file.py +1 -1
  19. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/run_shell.py +2 -2
  20. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/write_file.py +1 -1
  21. {scootcli-0.4.0 → scootcli-0.5.0/src/scootcli.egg-info}/PKG-INFO +10 -2
  22. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli.egg-info/SOURCES.txt +2 -0
  23. scootcli-0.5.0/tests/test_scope.py +225 -0
  24. {scootcli-0.4.0 → scootcli-0.5.0}/LICENSE +0 -0
  25. {scootcli-0.4.0 → scootcli-0.5.0}/pyproject.toml +0 -0
  26. {scootcli-0.4.0 → scootcli-0.5.0}/setup.cfg +0 -0
  27. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/__main__.py +0 -0
  28. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/activity.py +0 -0
  29. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/auth.py +0 -0
  30. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/clipboard.py +0 -0
  31. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/approve.py +0 -0
  32. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/auth.py +0 -0
  33. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/base.py +0 -0
  34. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/compact.py +0 -0
  35. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/copy.py +0 -0
  36. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/exit.py +0 -0
  37. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/forget.py +0 -0
  38. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/help.py +0 -0
  39. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/hooks.py +0 -0
  40. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/init.py +0 -0
  41. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/logo.py +0 -0
  42. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/model.py +0 -0
  43. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/panel.py +0 -0
  44. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/reset.py +0 -0
  45. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/resume.py +0 -0
  46. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/route.py +0 -0
  47. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/save.py +0 -0
  48. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/sessions.py +0 -0
  49. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/verbosity.py +0 -0
  50. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/worktree.py +0 -0
  51. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/commands/yolo.py +0 -0
  52. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/context.py +0 -0
  53. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/credentials.py +0 -0
  54. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/errors.py +0 -0
  55. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/hooks.py +0 -0
  56. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/images.py +0 -0
  57. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/keys.py +0 -0
  58. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/lineeditor.py +0 -0
  59. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/logo.py +0 -0
  60. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/models.py +0 -0
  61. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/panel.py +0 -0
  62. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/preferences.py +0 -0
  63. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/presets.py +0 -0
  64. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/project.py +0 -0
  65. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/providers/__init__.py +0 -0
  66. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/providers/anthropic.py +0 -0
  67. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/providers/base.py +0 -0
  68. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/providers/openai_chat.py +0 -0
  69. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/providers/openai_responses.py +0 -0
  70. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/providers/registry.py +0 -0
  71. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/providers/router.py +0 -0
  72. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/rendering.py +0 -0
  73. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/sessions.py +0 -0
  74. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/status.py +0 -0
  75. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/__init__.py +0 -0
  76. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/search.py +0 -0
  77. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/tools/update_plan.py +0 -0
  78. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/transport.py +0 -0
  79. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/vision.py +0 -0
  80. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/workspace.py +0 -0
  81. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli/worktree.py +0 -0
  82. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  83. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  84. {scootcli-0.4.0 → scootcli-0.5.0}/src/scootcli.egg-info/top_level.txt +0 -0
  85. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_agent.py +0 -0
  86. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_anthropic.py +0 -0
  87. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_approvals.py +0 -0
  88. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_auth.py +0 -0
  89. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_clipboard.py +0 -0
  90. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_config.py +0 -0
  91. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_headless.py +0 -0
  92. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_hooks.py +0 -0
  93. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_images.py +0 -0
  94. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_lineeditor.py +0 -0
  95. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_logo.py +0 -0
  96. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_models.py +0 -0
  97. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_panel.py +0 -0
  98. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_preferences.py +0 -0
  99. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_providers.py +0 -0
  100. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_readiness.py +0 -0
  101. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_repl.py +0 -0
  102. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_resilience.py +0 -0
  103. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_router.py +0 -0
  104. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_sessions.py +0 -0
  105. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_smoke.py +0 -0
  106. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_streaming.py +0 -0
  107. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_tools.py +0 -0
  108. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_transport_native.py +0 -0
  109. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_vision.py +0 -0
  110. {scootcli-0.4.0 → scootcli-0.5.0}/tests/test_workspace.py +0 -0
  111. {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.4.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
- Every tool is sandboxed to the workspace root.
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
- Every tool is sandboxed to the workspace root.
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.
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
4
4
 
@@ -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
- ctx = ToolContext(root=cfg.root, config=cfg, cancel_event=cancel_event)
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,
@@ -53,6 +53,7 @@ def load_builtins() -> None:
53
53
  from . import logo as _logo # noqa: F401
54
54
  from . import route as _route # noqa: F401
55
55
  from . import hooks as _hooks # noqa: F401
56
+ from . import scope as _scope # noqa: F401
56
57
 
57
58
  _loaded = True
58
59
 
@@ -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
- - All file paths you use must stay inside the root. Never touch paths outside it.
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 safe_path(root: Path, raw: str) -> Path:
106
- """Resolve ``raw`` against ``root`` and ensure it stays inside it. Raises PathEscapeError."""
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
- root = root.resolve()
110
- p = Path(raw)
163
+ p = Path(str(raw)).expanduser()
111
164
  if not p.is_absolute():
112
- p = root / p
113
- p = p.resolve()
114
- try:
115
- p.relative_to(root)
116
- except ValueError:
117
- raise PathEscapeError(f"path escapes workspace root: {raw}")
118
- return p
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 dir relative to root (optional)."},
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.4.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
- Every tool is sandboxed to the workspace root.
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