scootcli 0.9.0__tar.gz → 0.11.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 (120) hide show
  1. {scootcli-0.9.0/src/scootcli.egg-info → scootcli-0.11.0}/PKG-INFO +8 -5
  2. {scootcli-0.9.0 → scootcli-0.11.0}/README.md +7 -4
  3. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/agent.py +30 -21
  5. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/approvals.py +19 -5
  6. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/cli.py +28 -19
  7. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/hooks.py +21 -5
  8. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/config.py +76 -10
  9. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/credentials.py +3 -15
  10. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/headless.py +9 -5
  11. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/hooks.py +106 -5
  12. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/images.py +4 -3
  13. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/keys.py +13 -11
  14. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/preferences.py +36 -12
  15. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/prompts.py +9 -1
  16. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/providers/router.py +14 -8
  17. scootcli-0.11.0/src/scootcli/rendering.py +159 -0
  18. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/repl.py +56 -8
  19. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/sessions.py +50 -28
  20. scootcli-0.11.0/src/scootcli/store.py +47 -0
  21. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/base.py +91 -19
  22. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/read_file.py +7 -1
  23. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/search.py +37 -10
  24. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/workspace.py +4 -2
  25. {scootcli-0.9.0 → scootcli-0.11.0/src/scootcli.egg-info}/PKG-INFO +8 -5
  26. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli.egg-info/SOURCES.txt +3 -0
  27. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_agent.py +35 -0
  28. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_approvals.py +31 -0
  29. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_config.py +89 -0
  30. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_headless.py +28 -0
  31. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_hooks.py +212 -0
  32. scootcli-0.11.0/tests/test_rendering.py +75 -0
  33. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_router.py +16 -0
  34. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_scope.py +9 -2
  35. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_sessions.py +64 -0
  36. scootcli-0.11.0/tests/test_store.py +36 -0
  37. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_tools.py +129 -0
  38. scootcli-0.9.0/src/scootcli/rendering.py +0 -86
  39. {scootcli-0.9.0 → scootcli-0.11.0}/LICENSE +0 -0
  40. {scootcli-0.9.0 → scootcli-0.11.0}/pyproject.toml +0 -0
  41. {scootcli-0.9.0 → scootcli-0.11.0}/setup.cfg +0 -0
  42. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/__main__.py +0 -0
  43. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/activity.py +0 -0
  44. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/auth.py +0 -0
  45. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/clipboard.py +0 -0
  46. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/__init__.py +0 -0
  47. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/approve.py +0 -0
  48. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/auth.py +0 -0
  49. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/base.py +0 -0
  50. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/compact.py +0 -0
  51. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/copy.py +0 -0
  52. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/exit.py +0 -0
  53. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/forget.py +0 -0
  54. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/help.py +0 -0
  55. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/init.py +0 -0
  56. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/logo.py +0 -0
  57. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/model.py +0 -0
  58. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/panel.py +0 -0
  59. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/reset.py +0 -0
  60. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/resume.py +0 -0
  61. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/route.py +0 -0
  62. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/save.py +0 -0
  63. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/scope.py +0 -0
  64. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/sessions.py +0 -0
  65. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/status.py +0 -0
  66. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/verbosity.py +0 -0
  67. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/worktree.py +0 -0
  68. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/yolo.py +0 -0
  69. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/context.py +0 -0
  70. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/errors.py +0 -0
  71. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/lineeditor.py +0 -0
  72. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/logo.py +0 -0
  73. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/models.py +0 -0
  74. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/panel.py +0 -0
  75. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/presets.py +0 -0
  76. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/pricing.py +0 -0
  77. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/project.py +0 -0
  78. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/providers/__init__.py +0 -0
  79. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/providers/anthropic.py +0 -0
  80. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/providers/base.py +0 -0
  81. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/providers/openai_chat.py +0 -0
  82. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/providers/openai_responses.py +0 -0
  83. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/providers/registry.py +0 -0
  84. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/resize.py +0 -0
  85. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/status.py +0 -0
  86. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/__init__.py +0 -0
  87. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/edit_file.py +0 -0
  88. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/list_dir.py +0 -0
  89. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/open_editor.py +0 -0
  90. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/run_shell.py +0 -0
  91. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/update_plan.py +0 -0
  92. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/write_file.py +0 -0
  93. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/transport.py +0 -0
  94. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/updates.py +0 -0
  95. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/vision.py +0 -0
  96. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/worktree.py +0 -0
  97. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  98. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  99. {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli.egg-info/top_level.txt +0 -0
  100. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_anthropic.py +0 -0
  101. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_auth.py +0 -0
  102. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_clipboard.py +0 -0
  103. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_images.py +0 -0
  104. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_lineeditor.py +0 -0
  105. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_logo.py +0 -0
  106. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_models.py +0 -0
  107. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_panel.py +0 -0
  108. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_preferences.py +0 -0
  109. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_providers.py +0 -0
  110. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_readiness.py +0 -0
  111. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_repl.py +0 -0
  112. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_resilience.py +0 -0
  113. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_resize.py +0 -0
  114. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_small_060.py +0 -0
  115. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_smoke.py +0 -0
  116. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_streaming.py +0 -0
  117. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_transport_native.py +0 -0
  118. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_vision.py +0 -0
  119. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_workspace.py +0 -0
  120. {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scootcli
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: A tiny coding agent that goes where you point it
5
5
  Author: Sergey Nes
6
6
  License-Expression: MIT
@@ -321,10 +321,13 @@ New capability is a drop-in file: a tool in `tools/`, a slash command in `comman
321
321
 
322
322
  ## Security
323
323
 
324
- - API keys are read from the environment or from `~/.config/scoot/credentials.json` (directory `0700`, file `0600`), validated before saving, and never echoed; `rendering.redact()` masks key-shaped strings in all output.
325
- - Only allowlisted keys are imported from `.env` files.
326
- - All file and shell tools are sandboxed to the workspace root; destructive shell commands are always re-confirmed.
327
- - Saved sessions are owner-only with secrets redacted; `/forget all` removes them.
324
+ What scoot protects, and what it does not.
325
+
326
+ - **Your API keys.** Read from the environment or from `~/.config/scoot/credentials.json` (directory `0700`, file `0600`), validated before saving, never echoed, and removed from the environment of every hook scoot runs. Key-shaped strings are masked in error messages and in saved sessions (message text, tool arguments, and the text copies inside provider replay data; signed or encrypted replay fields are stored as received). Streamed model output is printed as it arrives and is not filtered.
327
+ - **A cloned repository cannot reconfigure you.** A project `.env` may choose the model, effort, and how the terminal looks. It may not set provider base URLs or proxies (which would send your key elsewhere), the approval mode, the scope, the root, hooks, or scoot's config and state directories; those come from flags, your environment, or `~/.config/scoot/.env`, and a project value for them is ignored and reported at start. A project's `.scoot/hooks.json` runs only after you review it and run `/hooks trust`; the trust is bound to the file's content, so an edited file asks again.
328
+ - **File tools are scoped, shell commands are not sandboxed.** `read_file`, `list_dir`, `search`, `write_file`, and `edit_file` stay inside the workspace plus the paths you grant, and never follow a symlink out of it. `run_shell` runs the command as you, with your normal access to files, network, and other programs; approving a command means exactly that. The denylist (`rm -rf`, `git push`, `sudo`, piping a download into a shell, and so on) is an accident guard that forces a confirmation in every mode, including when a project's hook says `allow` (a hook in your own global config can waive it, so an editor or phone bridge you installed still works); it is not a boundary.
329
+ - **The default is `yolo`.** Tool calls run without asking, except the denylist and paths outside the workspace. Use `--approval always` or `auto-read` on code you do not trust yet, and `/worktree start` to keep autonomous edits out of your checkout.
330
+ - **Saved sessions** are owner-only, keyed by workspace, and pruned to the last 20; `/forget all` removes them. Session ids are file names, never paths.
328
331
  - No third-party dependencies means no third-party code to audit.
329
332
 
330
333
  ## Contributing
@@ -296,10 +296,13 @@ New capability is a drop-in file: a tool in `tools/`, a slash command in `comman
296
296
 
297
297
  ## Security
298
298
 
299
- - API keys are read from the environment or from `~/.config/scoot/credentials.json` (directory `0700`, file `0600`), validated before saving, and never echoed; `rendering.redact()` masks key-shaped strings in all output.
300
- - Only allowlisted keys are imported from `.env` files.
301
- - All file and shell tools are sandboxed to the workspace root; destructive shell commands are always re-confirmed.
302
- - Saved sessions are owner-only with secrets redacted; `/forget all` removes them.
299
+ What scoot protects, and what it does not.
300
+
301
+ - **Your API keys.** Read from the environment or from `~/.config/scoot/credentials.json` (directory `0700`, file `0600`), validated before saving, never echoed, and removed from the environment of every hook scoot runs. Key-shaped strings are masked in error messages and in saved sessions (message text, tool arguments, and the text copies inside provider replay data; signed or encrypted replay fields are stored as received). Streamed model output is printed as it arrives and is not filtered.
302
+ - **A cloned repository cannot reconfigure you.** A project `.env` may choose the model, effort, and how the terminal looks. It may not set provider base URLs or proxies (which would send your key elsewhere), the approval mode, the scope, the root, hooks, or scoot's config and state directories; those come from flags, your environment, or `~/.config/scoot/.env`, and a project value for them is ignored and reported at start. A project's `.scoot/hooks.json` runs only after you review it and run `/hooks trust`; the trust is bound to the file's content, so an edited file asks again.
303
+ - **File tools are scoped, shell commands are not sandboxed.** `read_file`, `list_dir`, `search`, `write_file`, and `edit_file` stay inside the workspace plus the paths you grant, and never follow a symlink out of it. `run_shell` runs the command as you, with your normal access to files, network, and other programs; approving a command means exactly that. The denylist (`rm -rf`, `git push`, `sudo`, piping a download into a shell, and so on) is an accident guard that forces a confirmation in every mode, including when a project's hook says `allow` (a hook in your own global config can waive it, so an editor or phone bridge you installed still works); it is not a boundary.
304
+ - **The default is `yolo`.** Tool calls run without asking, except the denylist and paths outside the workspace. Use `--approval always` or `auto-read` on code you do not trust yet, and `/worktree start` to keep autonomous edits out of your checkout.
305
+ - **Saved sessions** are owner-only, keyed by workspace, and pruned to the last 20; `/forget all` removes them. Session ids are file names, never paths.
303
306
  - No third-party dependencies means no third-party code to audit.
304
307
 
305
308
  ## Contributing
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.9.0"
3
+ __version__ = "0.11.0"
4
4
 
@@ -18,7 +18,7 @@ from dataclasses import dataclass
18
18
  from typing import List, Optional
19
19
 
20
20
  from . import tools
21
- from .approvals import Approval, Decision, needs_prompt
21
+ from .approvals import Approval, Decision, denylisted_reason, needs_prompt
22
22
  from .config import Config
23
23
  from .errors import ScootError, ContextLengthError, Interrupted, ModelUnavailableError
24
24
  from .prompts import build_agent_system_prompt
@@ -40,6 +40,8 @@ _CUT_OFF = {
40
40
  "incomplete": "the stream ended before the reply was complete",
41
41
  }
42
42
 
43
+ _MAX_FALLBACKS = 6 # a turn's model-unavailable switches, so a bad router cannot spin forever
44
+
43
45
 
44
46
  def _strip_done(text: str) -> str:
45
47
  """Remove a trailing ``DONE`` sentinel line from the model's final message."""
@@ -112,6 +114,7 @@ class Agent:
112
114
  compacted = False
113
115
  stop_blocks = 0
114
116
  cut_off = 0 # tool batches the model could not finish (output limit); bounded retries
117
+ fallbacks = 0 # model-unavailable switches this turn; bounded independently of steps
115
118
  while True:
116
119
  if cancel_event.is_set():
117
120
  return AgentOutcome("interrupted", steps=steps)
@@ -124,8 +127,9 @@ class Agent:
124
127
  except Interrupted:
125
128
  return AgentOutcome("interrupted", steps=steps)
126
129
  except ModelUnavailableError as exc:
127
- if self._fallback_model(session):
128
- ui.assistant(f"model unavailable — switching to {session.active_model}.")
130
+ fallbacks += 1
131
+ if fallbacks <= _MAX_FALLBACKS and self._fallback_model(session):
132
+ ui.assistant(f"model unavailable, switching to {session.active_model}.")
129
133
  steps -= 1 # don't count the failed attempt
130
134
  continue
131
135
  return AgentOutcome("error", error=_fmt_error(exc), steps=steps)
@@ -235,10 +239,10 @@ class Agent:
235
239
  decision = hooks.run("Stop", payload)
236
240
  return (decision.reason or "the Stop hook asked to continue") if decision.blocks else ""
237
241
 
238
- def _pre_tool_hook(self, session, tool, args, cancel_event) -> "tuple[str, str]":
242
+ def _pre_tool_hook(self, session, tool, args, cancel_event) -> "tuple[str, str, bool]":
239
243
  hooks = self._hooks(session)
240
244
  if hooks is None or not hooks.has("PreToolUse"):
241
- return "", ""
245
+ return "", "", False
242
246
  from .hooks import tool_kind
243
247
 
244
248
  from .hooks import tool_alias
@@ -246,7 +250,7 @@ class Agent:
246
250
  payload = hooks.payload(session, "PreToolUse", tool_name=tool.name, tool_input=args,
247
251
  tool_kind=tool_kind(tool), tool_alias=tool_alias(tool.name))
248
252
  decision = hooks.run("PreToolUse", payload, cancel_event)
249
- return decision.action, decision.reason
253
+ return decision.action, decision.reason, decision.from_global
250
254
 
251
255
  def _post_tool_hook(self, session, tool, args, result: ToolResult) -> None:
252
256
  hooks = self._hooks(session)
@@ -288,10 +292,10 @@ class Agent:
288
292
  except Exception:
289
293
  available = []
290
294
  bad = getattr(session, "bad_models", None) or set()
291
- available = [m for m in available if m not in bad] or available
295
+ available = [m for m in available if m not in bad] # never re-pick a model that already failed
292
296
  hints = compute_hints(session)
293
297
  return router.choose(session, hints, available, session.resolved_model(),
294
- classify=self._classify_with_provider).model
298
+ classify=self._classify_with_provider, excluded=bad).model
295
299
 
296
300
  def _classify_with_provider(self, model: str, prompt: str) -> str:
297
301
  """One small, non-streaming call used by a configured router classifier."""
@@ -300,18 +304,15 @@ class Agent:
300
304
  return result.content or ""
301
305
 
302
306
  def _fallback_model(self, session) -> bool:
303
- """After a model-unavailable error, blacklist it and switch models. Returns False if stuck."""
304
- bad = session.active_model
305
- fallback = session.resolved_model()
306
- if bad == fallback and session.model.lower() != "auto":
307
- return False
307
+ """After a model-unavailable error, mark the failed model bad and switch. Returns False when
308
+ there is nothing new to try, so the loop cannot alternate forever between two dead models."""
309
+ failed = session.active_model
308
310
  if hasattr(session, "bad_models"):
309
- session.bad_models.add(bad)
311
+ session.bad_models.add(failed)
312
+ bad = getattr(session, "bad_models", None) or set()
310
313
  new = self._pick_model(session)
311
- if new == bad:
312
- if bad == fallback:
313
- return False
314
- new = fallback
314
+ if not new or new in bad:
315
+ return False # every candidate is already known unavailable this turn
315
316
  session.active_model = new
316
317
  return True
317
318
 
@@ -434,6 +435,8 @@ class Agent:
434
435
  for tc in tool_calls:
435
436
  if cancel_event.is_set():
436
437
  return self._interrupted(session, tool_calls)
438
+ if scope is not None:
439
+ scope.clear_once() # a prior call's one-shot path grant does not carry into this one
437
440
  tc_id = tc.get("id", "")
438
441
  fn = tc.get("function", {}) or {}
439
442
  name = fn.get("name", "")
@@ -461,7 +464,7 @@ class Agent:
461
464
  ui.tool_result(name, ToolResult(ok=False, summary="outside the workspace: declined"))
462
465
  continue
463
466
  # A PreToolUse hook may deny (skip the tool), allow (skip the prompt), or ask (force it).
464
- hook_action, hook_reason = self._pre_tool_hook(session, tool, args, cancel_event)
467
+ hook_action, hook_reason, hook_from_global = self._pre_tool_hook(session, tool, args, cancel_event)
465
468
  if cancel_event.is_set(): # ESC while the hook ran
466
469
  self._append_tool(session, tc_id, "not executed: the user interrupted the turn")
467
470
  return self._interrupted(session, tool_calls)
@@ -471,7 +474,13 @@ class Agent:
471
474
  continue
472
475
  # Approval policy: auto-approve when the mode/trust allows it, else prompt.
473
476
  must_prompt = needs_prompt(mode, tool, args, trusted)
474
- if hook_action == "allow":
477
+ # A hook may waive scoot's own approval prompt. It may also waive the denylist confirmation,
478
+ # but only when it lives in the user's own global config: that is the user's machine-level
479
+ # delegation (an editor or a phone bridge approving on their behalf). A project's hook, even
480
+ # one trusted to run, is repo-authored and must not silently auto-run a catastrophic command.
481
+ waivable = hook_action == "allow" and (hook_from_global or not (
482
+ tool.name == "run_shell" and denylisted_reason(args.get("command", ""))))
483
+ if waivable:
475
484
  must_prompt = None
476
485
  elif hook_action == "ask":
477
486
  must_prompt = hook_reason or "hook asked for confirmation"
@@ -553,7 +562,7 @@ class Agent:
553
562
  verdict = asker(tool, paths[0], ctx)
554
563
  if verdict == "once":
555
564
  for p in paths:
556
- scope.grant(p)
565
+ scope.grant_once(p) # this call only; re-asked next time
557
566
  elif verdict == "dir":
558
567
  for p in paths:
559
568
  scope.grant_dir(p)
@@ -46,9 +46,13 @@ MODES = ("always", "auto-read", "auto-edits", "yolo")
46
46
 
47
47
  # Catastrophic shell commands that are ALWAYS confirmed, even in yolo mode.
48
48
  _DENYLIST = [
49
- (re.compile(r"\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r", re.I), "recursive force delete"),
49
+ # rm with a recursive flag and a force flag anywhere in the same command (``-rf``, ``-r -f``,
50
+ # ``-fR``, ``--recursive --force``); ``[^|;&]*`` keeps both lookaheads inside one command.
51
+ (re.compile(r"\brm\b(?=[^|;&]*\s-(?:[a-z]*r[a-z]*|-recursive)\b)(?=[^|;&]*\s-(?:[a-z]*f[a-z]*|-force)\b)", re.I),
52
+ "recursive force delete"),
50
53
  (re.compile(r":\(\)\s*\{.*\|.*&\s*\}\s*;", re.S), "fork bomb"),
51
- (re.compile(r"\bgit\s+push\b", re.I), "git push"),
54
+ # git push with any options between ``git`` and ``push`` (``-C repo``, ``--git-dir=x``, ``-c k=v``).
55
+ (re.compile(r"\bgit\b(?:\s+-{1,2}[\w-]+(?:[= ]\S+)?)*\s+push\b", re.I), "git push"),
52
56
  (re.compile(r"\bsudo\b", re.I), "sudo"),
53
57
  (re.compile(r"\b(shutdown|reboot|halt|poweroff)\b", re.I), "power/shutdown"),
54
58
  (re.compile(r"\bmkfs\b|\bdd\s+.*of=/dev/", re.I), "disk/format command"),
@@ -101,8 +105,10 @@ def _render(tool: Tool, args: dict, ctx: ToolContext) -> None:
101
105
  except Exception as exc: # a preview must never crash the loop
102
106
  preview = color(f"(preview unavailable: {exc})", "gray")
103
107
  if preview:
108
+ from .rendering import strip_controls
109
+
104
110
  for line in preview.splitlines():
105
- print(" " + line)
111
+ print(" " + strip_controls(line))
106
112
 
107
113
 
108
114
  def _edit_args(args: dict) -> dict:
@@ -129,6 +135,14 @@ def _edit_args(args: dict) -> dict:
129
135
  SCOPE_CHOICES = ("once", "dir", "all", "deny", "abort")
130
136
 
131
137
 
138
+ def _menu_key() -> str:
139
+ """One keypress for the approval menus: ``A`` (the session-wide choice) keeps its case, every other
140
+ key is lowercased so ``T`` still means trust. ``read_key`` used to lowercase everything, which made
141
+ the advertised ``[A]`` unreachable."""
142
+ key = read_key(keep_case=True)
143
+ return key if key == "A" else key.lower()
144
+
145
+
132
146
  def request_scope(tool: Tool, path, ctx: ToolContext) -> str:
133
147
  """Ask once about a path outside the workspace. Returns one of ``SCOPE_CHOICES``."""
134
148
  from pathlib import Path as _P
@@ -146,7 +160,7 @@ def request_scope(tool: Tool, path, ctx: ToolContext) -> str:
146
160
  + color("quit", "red") + " [q] › ",
147
161
  end="", flush=True,
148
162
  )
149
- key = read_key()
163
+ key = _menu_key()
150
164
  print(key)
151
165
  if key in ("a", "y", "\r", "\n", ""):
152
166
  return "once"
@@ -181,7 +195,7 @@ def request_approval(tool: Tool, args: dict, ctx: ToolContext) -> Approval:
181
195
  end="",
182
196
  flush=True,
183
197
  )
184
- key = read_key()
198
+ key = _menu_key()
185
199
  print(key) # echo the choice for a clean transcript
186
200
 
187
201
  if key in ("a", "y", "\r", "\n", ""):
@@ -15,6 +15,7 @@ Global flags override config: ``--model``, ``--proxy``, ``--root``,
15
15
  from __future__ import annotations
16
16
 
17
17
  import argparse
18
+ from pathlib import Path
18
19
  import json as _json
19
20
  import sys
20
21
  from typing import List, Optional
@@ -23,7 +24,7 @@ from .providers import ProviderPool
23
24
  from . import __version__
24
25
  from .config import Config
25
26
  from .errors import ScootError
26
- from .rendering import color, eprint, redact
27
+ from .rendering import color, eprint, redact, strip_controls
27
28
 
28
29
 
29
30
  def _build_parser() -> argparse.ArgumentParser:
@@ -89,7 +90,10 @@ def _build_parser() -> argparse.ArgumentParser:
89
90
 
90
91
 
91
92
  def _config_from_args(args: argparse.Namespace) -> Config:
92
- return Config.load().override(
93
+ # The workspace decides which project .env applies, so resolve --root before loading; otherwise
94
+ # `scoot --root ~/other` would take the launch directory's settings into the other project.
95
+ start = Path(args.root).expanduser().resolve() if args.root else None
96
+ return Config.load(cwd=start).override(
93
97
  model=args.model,
94
98
  provider=args.provider,
95
99
  effort=args.effort,
@@ -146,30 +150,40 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
146
150
  import threading
147
151
 
148
152
  from .agent import Agent
149
- from .repl import ReplSession, ReplUI
153
+ from .repl import OneShotJsonUI, ReplSession, ReplUI
154
+
155
+ def _emit_json(status: str, *, model: str = "", steps: int = 0, content: str = "",
156
+ error: str = "", usage=None, cost=None) -> None:
157
+ """The one and only object written to stdout in --json mode, on every exit path."""
158
+ print(_json.dumps({"status": status, "model": model, "steps": steps, "content": content,
159
+ "error": error, "usage": usage or {}, "cost": cost}, indent=2))
150
160
 
151
161
  from .providers.registry import readiness
152
162
 
153
163
  ready, message = readiness(config)
154
164
  if not ready: # fail fast with guidance instead of a connection error after retries
155
165
  if as_json:
156
- print(_json.dumps({"status": "error", "model": "", "steps": 0, "content": "",
157
- "error": message, "usage": {}}, indent=2))
166
+ _emit_json("error", error=message)
158
167
  else:
159
168
  eprint(color(message, "yellow"))
160
169
  return 1
161
170
  session = ReplSession(config, pool)
162
171
  if resume is not None:
163
172
  session.apply_record(resume)
164
- from .hooks import Hooks, session_event, submit_prompt
173
+ from .hooks import Hooks, session_event, startup_notices, submit_prompt
165
174
 
166
175
  session.hooks = Hooks(config.root)
176
+ for note in startup_notices(session):
177
+ eprint(color(f"⚠ {note}", "yellow"))
167
178
  session_event(session, "SessionStart", source="resume" if resume is not None else "startup")
168
179
  submitted = submit_prompt(session, prompt)
169
180
  if submitted is None:
170
181
  reason = getattr(session, "hook_block_reason", "") or "a UserPromptSubmit hook blocked it"
171
- eprint(color(f"⏹ prompt not sent: {reason}", "yellow"))
172
182
  session_event(session, "SessionEnd", reason="blocked")
183
+ if as_json:
184
+ _emit_json("blocked", model=session.active_model, error=reason)
185
+ else:
186
+ eprint(color(f"⏹ prompt not sent: {reason}", "yellow"))
173
187
  return 1
174
188
  prompt = submitted
175
189
  # Fold any dropped image paths into the prompt (best-effort; no-op when none/disabled).
@@ -182,25 +196,20 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
182
196
  session.messages.append({"role": "user", "content": prompt})
183
197
  # JSON output must be clean, so never stream tokens to stdout in that mode.
184
198
  agent_config = config.override(stream=False) if as_json else config
185
- outcome = Agent(agent_config, pool).run_turn(session, ReplUI(), threading.Event())
199
+ ui = OneShotJsonUI() if as_json else ReplUI()
200
+ outcome = Agent(agent_config, pool).run_turn(session, ui, threading.Event())
186
201
  session.autosave() # persist so `scoot -c` can continue this conversation
187
202
  session_event(session, "SessionEnd", reason=outcome.status)
188
203
 
189
204
  if as_json:
190
- print(_json.dumps({
191
- "status": outcome.status,
192
- "model": session.active_model,
193
- "steps": outcome.steps,
194
- "content": outcome.content,
195
- "error": outcome.error,
196
- "usage": session.last_usage,
197
- "cost": session.session_cost(),
198
- }, indent=2))
205
+ _emit_json(outcome.status, model=session.active_model, steps=outcome.steps,
206
+ content=outcome.content, error=outcome.error, usage=session.last_usage,
207
+ cost=session.session_cost())
199
208
  return 0 if outcome.status == "done" else 1
200
209
 
201
210
  if outcome.status == "done":
202
211
  if outcome.content.strip() and not outcome.streamed:
203
- print(outcome.content.strip())
212
+ print(strip_controls(outcome.content.strip()))
204
213
  if config.verbose:
205
214
  from .pricing import fmt
206
215
 
@@ -211,7 +220,7 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
211
220
  f"cost={fmt(session.session_cost())}", "gray"))
212
221
  return 0
213
222
  if outcome.status == "incomplete" and outcome.content.strip() and not outcome.streamed:
214
- print(outcome.content.strip())
223
+ print(strip_controls(outcome.content.strip()))
215
224
  eprint(color(f"⚠ {redact(outcome.error or outcome.status)}", "red"))
216
225
  return 1
217
226
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from ..hooks import EVENTS, enabled, global_path, project_path
5
+ from ..hooks import EVENTS, enabled, global_path, project_path, project_trust, trust_project, untrust_project
6
6
  from ..rendering import color
7
7
  from . import register
8
8
  from .base import SlashCommand
@@ -10,14 +10,29 @@ from .base import SlashCommand
10
10
 
11
11
  def _run(session, args: str):
12
12
  hooks = getattr(session, "hooks", None)
13
- if (args or "").strip() == "reload" and hooks is not None:
13
+ sub = (args or "").strip().lower()
14
+ root = getattr(getattr(session, "config", None), "root", ".")
15
+ if sub == "trust":
16
+ if trust_project(root):
17
+ print(color(f"trusted {project_path(root)}; its hooks run from now on (until the file changes).", "gray"))
18
+ else:
19
+ print(color(f"no project hooks file at {project_path(root)}.", "yellow"))
20
+ sub = "reload"
21
+ elif sub == "untrust":
22
+ print(color("project hooks are off again." if untrust_project(root) else "project hooks were not trusted.", "gray"))
23
+ sub = "reload"
24
+ if sub == "reload" and hooks is not None:
14
25
  hooks.reload()
15
26
  print(color("hooks reloaded.", "gray"))
16
27
  print(color("hooks:", "bold"))
17
28
  if not enabled():
18
29
  print(color(" disabled by SCOOT_HOOKS=0", "yellow"))
19
- root = getattr(getattr(session, "config", None), "root", ".")
20
30
  print(color(f" files: {project_path(root)} (project), {global_path()} (global)", "gray"))
31
+ trust = project_trust(root)
32
+ if trust == "untrusted":
33
+ print(color(" project hooks: NOT trusted, not running. review the file, then /hooks trust", "yellow"))
34
+ elif trust == "trusted":
35
+ print(color(" project hooks: trusted (/hooks untrust to turn them off)", "gray"))
21
36
  if hooks is None or not hooks.config:
22
37
  print(color(" none configured", "gray"))
23
38
  else:
@@ -31,7 +46,8 @@ def _run(session, args: str):
31
46
  for r in hooks.history[-8:]:
32
47
  detail = f" {r.detail[:60]}" if r.detail else ""
33
48
  print(f" {r.event:<16} {r.outcome:<8} {r.seconds:>5.2f}s {r.command[:40]}{detail}")
34
- print(color(" usage: /hooks [reload]", "gray"))
49
+ print(color(" usage: /hooks [reload|trust|untrust]", "gray"))
35
50
 
36
51
 
37
- register(SlashCommand("hooks", "list configured hooks and recent results", _run, usage="[reload]"))
52
+ register(SlashCommand("hooks", "list configured hooks and recent results; trust a project's hooks", _run,
53
+ usage="[reload|trust|untrust]"))
@@ -12,6 +12,13 @@ Only scoot's own keys are imported from them: ``SCOOT_*``, the providers' API-ke
12
12
  (``OPENAI_API_KEY``, ...), and the proxy variables. A project's other secrets never enter scoot's
13
13
  process through a ``.env`` file.
14
14
 
15
+ The project file gets a narrower allowlist than the global one. A repository is untrusted input:
16
+ its ``.env`` may choose what the model is asked and how the terminal looks, but not where requests
17
+ go (base URLs, proxies), what runs on this machine (hooks), what the tools may touch (approval,
18
+ scope, root), or where scoot's own files live (config and state directories). Those keys come from
19
+ flags, the real environment, or the user's global file; a project value for them is ignored and
20
+ reported, never applied.
21
+
15
22
  The CLI-flag layer is applied by the caller (``cli.py``) via :meth:`Config.override`; this module
16
23
  handles the lower layers.
17
24
  """
@@ -21,6 +28,8 @@ from __future__ import annotations
21
28
  import os
22
29
  from dataclasses import dataclass, replace
23
30
  from pathlib import Path
31
+
32
+ from .errors import ConfigError
24
33
  from typing import List, Optional, Tuple
25
34
 
26
35
  # No proxy by default (set HTTPS_PROXY / --proxy when one is required).
@@ -91,16 +100,37 @@ def allowed_env_key(key: str) -> bool:
91
100
  return False
92
101
 
93
102
 
94
- def load_dotenv(env_path: Path, allow=allowed_env_key) -> List[str]:
95
- """Import allowed ``KEY=VALUE`` lines into ``os.environ`` (never overriding); return imported keys.
103
+ # Keys a *project* .env may not set: they move requests, run code, widen what tools may touch, or
104
+ # relocate scoot's own files. Anything a cloned repository must not be able to decide for you.
105
+ _PROJECT_BLOCKED_KEYS = frozenset({
106
+ "SCOOT_APPROVAL", "SCOOT_SCOPE", "SCOOT_ROOT", "SCOOT_HOOKS", "SCOOT_CONFIG_DIR", "SCOOT_STATE_DIR",
107
+ *_PROXY_KEYS,
108
+ })
109
+
110
+
111
+ def project_env_key_allowed(key: str) -> bool:
112
+ """The project layer's allowlist: scoot's keys minus the ones that move data or grant power.
113
+
114
+ A base URL override (``SCOOT_<PROVIDER>_BASE_URL``) is the sharpest of them: with it a repository
115
+ could send the user's own API key to a host of its choosing on the first request.
116
+ """
117
+ if not allowed_env_key(key):
118
+ return False
119
+ if key in _PROJECT_BLOCKED_KEYS or (key.startswith("SCOOT_") and key.endswith("_BASE_URL")):
120
+ return False
121
+ return True
122
+
123
+
124
+ def parse_dotenv(env_path: Path) -> List[Tuple[str, str]]:
125
+ """``(key, value)`` pairs from a ``.env`` file, in order; unreadable files yield nothing.
96
126
 
97
127
  Accepts an optional ``export`` prefix and single or double quotes around the value.
98
128
  """
99
- imported: List[str] = []
129
+ pairs: List[Tuple[str, str]] = []
100
130
  try:
101
131
  lines = env_path.read_text().splitlines()
102
132
  except OSError:
103
- return imported
133
+ return pairs
104
134
  for line in lines:
105
135
  line = line.strip()
106
136
  if not line or line.startswith("#") or "=" not in line:
@@ -109,17 +139,32 @@ def load_dotenv(env_path: Path, allow=allowed_env_key) -> List[str]:
109
139
  line = line[len("export "):].lstrip()
110
140
  key, _, value = line.partition("=")
111
141
  key = key.strip()
112
- if not key or not allow(key):
142
+ if not key:
113
143
  continue
114
144
  value = value.strip()
115
145
  if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
116
146
  value = value[1:-1]
147
+ pairs.append((key, value))
148
+ return pairs
149
+
150
+
151
+ def load_dotenv(env_path: Path, allow=allowed_env_key) -> List[str]:
152
+ """Import allowed ``KEY=VALUE`` lines into ``os.environ`` (never overriding); return imported keys."""
153
+ imported: List[str] = []
154
+ for key, value in parse_dotenv(env_path):
155
+ if not allow(key):
156
+ continue
117
157
  if key not in os.environ:
118
158
  os.environ[key] = value
119
159
  imported.append(key)
120
160
  return imported
121
161
 
122
162
 
163
+ def rejected_project_keys(env_path: Path) -> List[str]:
164
+ """Keys in a project ``.env`` that scoot knows but will not take from a repository."""
165
+ return [k for k, _ in parse_dotenv(env_path) if allowed_env_key(k) and not project_env_key_allowed(k)]
166
+
167
+
123
168
  def _as_bool(value: str) -> bool:
124
169
  return value.strip().lower() not in ("false", "0", "no", "off", "")
125
170
 
@@ -192,6 +237,7 @@ class Config:
192
237
  editor: str = "idea" # external editor for the open_editor tool: idea | vscode
193
238
  scope: str = "workspace" # where file tools may go: workspace (ask once outside it) | anywhere
194
239
  env_files: Tuple[str, ...] = () # the .env files that were read, in load order (shown by /status)
240
+ ignored_project_keys: Tuple[str, ...] = () # keys the project .env tried to set that only the user may set
195
241
  logo: bool = True # mascot in the launch banner + status-bar face (--no-logo / SCOOT_LOGO / /logo)
196
242
  emoji: bool = True # 🛴 transcript label; off → ⏺ for terminals without an emoji font (--no-emoji)
197
243
 
@@ -201,22 +247,42 @@ class Config:
201
247
  """Load config from the ``.env`` files + environment variables, falling back to defaults."""
202
248
  cwd = (cwd or Path.cwd()).resolve()
203
249
  files = env_files(cwd)
250
+ project = find_project_env(cwd)
251
+ ignored: List[str] = []
204
252
  for env_file in files:
205
- load_dotenv(env_file)
253
+ if env_file == project:
254
+ load_dotenv(env_file, allow=project_env_key_allowed)
255
+ ignored = rejected_project_keys(env_file)
256
+ else:
257
+ load_dotenv(env_file)
206
258
 
207
259
  get = os.environ.get
260
+
261
+ def _int(key: str, default: int, low: int, high: int) -> int:
262
+ raw = get(key)
263
+ if raw is None or raw.strip() == "":
264
+ return default
265
+ try:
266
+ value = int(raw.strip())
267
+ except ValueError:
268
+ raise ConfigError(f"{key} must be an integer, got {raw!r}")
269
+ if not (low <= value <= high):
270
+ raise ConfigError(f"{key} must be between {low} and {high}, got {value}")
271
+ return value
272
+
208
273
  root = Path(get("SCOOT_ROOT", str(cwd))).resolve()
209
274
  model, model_source = _resolve_model_setting(get("SCOOT_MODEL"), root)
210
275
  return cls(
211
276
  proxy=get("HTTPS_PROXY") or get("https_proxy") or DEFAULT_PROXY,
212
277
  env_files=tuple(str(f) for f in files),
278
+ ignored_project_keys=tuple(ignored),
213
279
  provider=get("SCOOT_PROVIDER", "").strip().lower(),
214
280
  model=model,
215
281
  model_source=model_source,
216
282
  effort=get("SCOOT_EFFORT", "medium").strip().lower(),
217
- timeout=int(get("SCOOT_TIMEOUT", "120")),
218
- max_steps=int(get("SCOOT_MAX_STEPS", "50")),
219
- compact_at=int(get("SCOOT_COMPACT_AT", "100000")),
283
+ timeout=_int("SCOOT_TIMEOUT", 120, 1, 86_400),
284
+ max_steps=_int("SCOOT_MAX_STEPS", 50, 1, 10_000),
285
+ compact_at=_int("SCOOT_COMPACT_AT", 100_000, 1_000, 100_000_000),
220
286
  approval=get("SCOOT_APPROVAL", "yolo"),
221
287
  root=root,
222
288
  verbose=_as_bool(get("SCOOT_VERBOSE", "false")),
@@ -229,7 +295,7 @@ class Config:
229
295
  verbosity=get("SCOOT_VERBOSITY", "full").strip().lower(),
230
296
  images=_as_bool(get("SCOOT_IMAGES", "true")),
231
297
  vision_model=get("SCOOT_VISION_MODEL", "auto"),
232
- image_max_bytes=int(get("SCOOT_IMAGE_MAX_BYTES", str(4 * 1024 * 1024))),
298
+ image_max_bytes=_int("SCOOT_IMAGE_MAX_BYTES", 4 * 1024 * 1024, 1024, 512 * 1024 * 1024),
233
299
  editor=get("SCOOT_EDITOR", "idea").strip().lower(),
234
300
  scope=get("SCOOT_SCOPE", "workspace").strip().lower(),
235
301
  logo=_resolve_logo_setting(get("SCOOT_LOGO")),
@@ -32,21 +32,9 @@ def _load() -> Dict[str, str]:
32
32
 
33
33
 
34
34
  def _write(keys: Dict[str, str]) -> Path:
35
- directory = _file().parent
36
- directory.mkdir(parents=True, exist_ok=True)
37
- try:
38
- os.chmod(directory, 0o700)
39
- except OSError:
40
- pass # best-effort on exotic filesystems
41
- # Open with a restrictive mode from the start so a key is never briefly world-readable.
42
- fd = os.open(_file(), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
43
- with os.fdopen(fd, "w") as fh:
44
- json.dump({"keys": keys}, fh)
45
- try:
46
- os.chmod(_file(), 0o600) # tighten in case the file pre-existed
47
- except OSError:
48
- pass
49
- return _file()
35
+ from .store import write_json_atomic
36
+
37
+ return write_json_atomic(_file(), {"keys": keys})
50
38
 
51
39
 
52
40
  def load_key(provider: str) -> Optional[str]: