scootcli 0.9.0__tar.gz → 0.10.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 (117) hide show
  1. {scootcli-0.9.0/src/scootcli.egg-info → scootcli-0.10.0}/PKG-INFO +8 -5
  2. {scootcli-0.9.0 → scootcli-0.10.0}/README.md +7 -4
  3. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/agent.py +12 -6
  5. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/approvals.py +16 -4
  6. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/cli.py +8 -2
  7. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/hooks.py +21 -5
  8. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/config.py +57 -6
  9. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/headless.py +9 -5
  10. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/hooks.py +106 -5
  11. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/images.py +4 -3
  12. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/keys.py +13 -11
  13. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/preferences.py +33 -0
  14. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/rendering.py +52 -0
  15. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/repl.py +12 -3
  16. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/sessions.py +47 -22
  17. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/read_file.py +7 -1
  18. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/search.py +25 -4
  19. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/workspace.py +4 -2
  20. {scootcli-0.9.0 → scootcli-0.10.0/src/scootcli.egg-info}/PKG-INFO +8 -5
  21. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli.egg-info/SOURCES.txt +1 -0
  22. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_approvals.py +31 -0
  23. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_config.py +65 -0
  24. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_headless.py +28 -0
  25. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_hooks.py +212 -0
  26. scootcli-0.10.0/tests/test_rendering.py +53 -0
  27. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_sessions.py +64 -0
  28. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_tools.py +73 -0
  29. {scootcli-0.9.0 → scootcli-0.10.0}/LICENSE +0 -0
  30. {scootcli-0.9.0 → scootcli-0.10.0}/pyproject.toml +0 -0
  31. {scootcli-0.9.0 → scootcli-0.10.0}/setup.cfg +0 -0
  32. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/__main__.py +0 -0
  33. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/activity.py +0 -0
  34. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/auth.py +0 -0
  35. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/clipboard.py +0 -0
  36. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/__init__.py +0 -0
  37. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/approve.py +0 -0
  38. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/auth.py +0 -0
  39. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/base.py +0 -0
  40. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/compact.py +0 -0
  41. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/copy.py +0 -0
  42. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/exit.py +0 -0
  43. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/forget.py +0 -0
  44. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/help.py +0 -0
  45. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/init.py +0 -0
  46. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/logo.py +0 -0
  47. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/model.py +0 -0
  48. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/panel.py +0 -0
  49. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/reset.py +0 -0
  50. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/resume.py +0 -0
  51. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/route.py +0 -0
  52. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/save.py +0 -0
  53. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/scope.py +0 -0
  54. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/sessions.py +0 -0
  55. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/status.py +0 -0
  56. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/verbosity.py +0 -0
  57. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/worktree.py +0 -0
  58. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/yolo.py +0 -0
  59. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/context.py +0 -0
  60. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/credentials.py +0 -0
  61. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/errors.py +0 -0
  62. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/lineeditor.py +0 -0
  63. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/logo.py +0 -0
  64. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/models.py +0 -0
  65. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/panel.py +0 -0
  66. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/presets.py +0 -0
  67. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/pricing.py +0 -0
  68. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/project.py +0 -0
  69. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/prompts.py +0 -0
  70. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/providers/__init__.py +0 -0
  71. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/providers/anthropic.py +0 -0
  72. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/providers/base.py +0 -0
  73. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/providers/openai_chat.py +0 -0
  74. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/providers/openai_responses.py +0 -0
  75. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/providers/registry.py +0 -0
  76. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/providers/router.py +0 -0
  77. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/resize.py +0 -0
  78. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/status.py +0 -0
  79. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/__init__.py +0 -0
  80. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/base.py +0 -0
  81. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/edit_file.py +0 -0
  82. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/list_dir.py +0 -0
  83. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/open_editor.py +0 -0
  84. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/run_shell.py +0 -0
  85. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/update_plan.py +0 -0
  86. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/write_file.py +0 -0
  87. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/transport.py +0 -0
  88. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/updates.py +0 -0
  89. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/vision.py +0 -0
  90. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/worktree.py +0 -0
  91. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  92. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  93. {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli.egg-info/top_level.txt +0 -0
  94. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_agent.py +0 -0
  95. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_anthropic.py +0 -0
  96. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_auth.py +0 -0
  97. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_clipboard.py +0 -0
  98. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_images.py +0 -0
  99. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_lineeditor.py +0 -0
  100. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_logo.py +0 -0
  101. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_models.py +0 -0
  102. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_panel.py +0 -0
  103. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_preferences.py +0 -0
  104. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_providers.py +0 -0
  105. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_readiness.py +0 -0
  106. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_repl.py +0 -0
  107. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_resilience.py +0 -0
  108. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_resize.py +0 -0
  109. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_router.py +0 -0
  110. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_scope.py +0 -0
  111. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_small_060.py +0 -0
  112. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_smoke.py +0 -0
  113. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_streaming.py +0 -0
  114. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_transport_native.py +0 -0
  115. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_vision.py +0 -0
  116. {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_workspace.py +0 -0
  117. {scootcli-0.9.0 → scootcli-0.10.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.10.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.10.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
@@ -235,10 +235,10 @@ class Agent:
235
235
  decision = hooks.run("Stop", payload)
236
236
  return (decision.reason or "the Stop hook asked to continue") if decision.blocks else ""
237
237
 
238
- def _pre_tool_hook(self, session, tool, args, cancel_event) -> "tuple[str, str]":
238
+ def _pre_tool_hook(self, session, tool, args, cancel_event) -> "tuple[str, str, bool]":
239
239
  hooks = self._hooks(session)
240
240
  if hooks is None or not hooks.has("PreToolUse"):
241
- return "", ""
241
+ return "", "", False
242
242
  from .hooks import tool_kind
243
243
 
244
244
  from .hooks import tool_alias
@@ -246,7 +246,7 @@ class Agent:
246
246
  payload = hooks.payload(session, "PreToolUse", tool_name=tool.name, tool_input=args,
247
247
  tool_kind=tool_kind(tool), tool_alias=tool_alias(tool.name))
248
248
  decision = hooks.run("PreToolUse", payload, cancel_event)
249
- return decision.action, decision.reason
249
+ return decision.action, decision.reason, decision.from_global
250
250
 
251
251
  def _post_tool_hook(self, session, tool, args, result: ToolResult) -> None:
252
252
  hooks = self._hooks(session)
@@ -461,7 +461,7 @@ class Agent:
461
461
  ui.tool_result(name, ToolResult(ok=False, summary="outside the workspace: declined"))
462
462
  continue
463
463
  # 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)
464
+ hook_action, hook_reason, hook_from_global = self._pre_tool_hook(session, tool, args, cancel_event)
465
465
  if cancel_event.is_set(): # ESC while the hook ran
466
466
  self._append_tool(session, tc_id, "not executed: the user interrupted the turn")
467
467
  return self._interrupted(session, tool_calls)
@@ -471,7 +471,13 @@ class Agent:
471
471
  continue
472
472
  # Approval policy: auto-approve when the mode/trust allows it, else prompt.
473
473
  must_prompt = needs_prompt(mode, tool, args, trusted)
474
- if hook_action == "allow":
474
+ # A hook may waive scoot's own approval prompt. It may also waive the denylist confirmation,
475
+ # but only when it lives in the user's own global config: that is the user's machine-level
476
+ # delegation (an editor or a phone bridge approving on their behalf). A project's hook, even
477
+ # one trusted to run, is repo-authored and must not silently auto-run a catastrophic command.
478
+ waivable = hook_action == "allow" and (hook_from_global or not (
479
+ tool.name == "run_shell" and denylisted_reason(args.get("command", ""))))
480
+ if waivable:
475
481
  must_prompt = None
476
482
  elif hook_action == "ask":
477
483
  must_prompt = hook_reason or "hook asked for confirmation"
@@ -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"),
@@ -129,6 +133,14 @@ def _edit_args(args: dict) -> dict:
129
133
  SCOPE_CHOICES = ("once", "dir", "all", "deny", "abort")
130
134
 
131
135
 
136
+ def _menu_key() -> str:
137
+ """One keypress for the approval menus: ``A`` (the session-wide choice) keeps its case, every other
138
+ key is lowercased so ``T`` still means trust. ``read_key`` used to lowercase everything, which made
139
+ the advertised ``[A]`` unreachable."""
140
+ key = read_key(keep_case=True)
141
+ return key if key == "A" else key.lower()
142
+
143
+
132
144
  def request_scope(tool: Tool, path, ctx: ToolContext) -> str:
133
145
  """Ask once about a path outside the workspace. Returns one of ``SCOPE_CHOICES``."""
134
146
  from pathlib import Path as _P
@@ -146,7 +158,7 @@ def request_scope(tool: Tool, path, ctx: ToolContext) -> str:
146
158
  + color("quit", "red") + " [q] › ",
147
159
  end="", flush=True,
148
160
  )
149
- key = read_key()
161
+ key = _menu_key()
150
162
  print(key)
151
163
  if key in ("a", "y", "\r", "\n", ""):
152
164
  return "once"
@@ -181,7 +193,7 @@ def request_approval(tool: Tool, args: dict, ctx: ToolContext) -> Approval:
181
193
  end="",
182
194
  flush=True,
183
195
  )
184
- key = read_key()
196
+ key = _menu_key()
185
197
  print(key) # echo the choice for a clean transcript
186
198
 
187
199
  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
@@ -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,
@@ -161,9 +165,11 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
161
165
  session = ReplSession(config, pool)
162
166
  if resume is not None:
163
167
  session.apply_record(resume)
164
- from .hooks import Hooks, session_event, submit_prompt
168
+ from .hooks import Hooks, session_event, startup_notices, submit_prompt
165
169
 
166
170
  session.hooks = Hooks(config.root)
171
+ for note in startup_notices(session):
172
+ eprint(color(f"⚠ {note}", "yellow"))
167
173
  session_event(session, "SessionStart", source="resume" if resume is not None else "startup")
168
174
  submitted = submit_prompt(session, prompt)
169
175
  if submitted is None:
@@ -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
  """
@@ -91,16 +98,37 @@ def allowed_env_key(key: str) -> bool:
91
98
  return False
92
99
 
93
100
 
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.
101
+ # Keys a *project* .env may not set: they move requests, run code, widen what tools may touch, or
102
+ # relocate scoot's own files. Anything a cloned repository must not be able to decide for you.
103
+ _PROJECT_BLOCKED_KEYS = frozenset({
104
+ "SCOOT_APPROVAL", "SCOOT_SCOPE", "SCOOT_ROOT", "SCOOT_HOOKS", "SCOOT_CONFIG_DIR", "SCOOT_STATE_DIR",
105
+ *_PROXY_KEYS,
106
+ })
107
+
108
+
109
+ def project_env_key_allowed(key: str) -> bool:
110
+ """The project layer's allowlist: scoot's keys minus the ones that move data or grant power.
111
+
112
+ A base URL override (``SCOOT_<PROVIDER>_BASE_URL``) is the sharpest of them: with it a repository
113
+ could send the user's own API key to a host of its choosing on the first request.
114
+ """
115
+ if not allowed_env_key(key):
116
+ return False
117
+ if key in _PROJECT_BLOCKED_KEYS or (key.startswith("SCOOT_") and key.endswith("_BASE_URL")):
118
+ return False
119
+ return True
120
+
121
+
122
+ def parse_dotenv(env_path: Path) -> List[Tuple[str, str]]:
123
+ """``(key, value)`` pairs from a ``.env`` file, in order; unreadable files yield nothing.
96
124
 
97
125
  Accepts an optional ``export`` prefix and single or double quotes around the value.
98
126
  """
99
- imported: List[str] = []
127
+ pairs: List[Tuple[str, str]] = []
100
128
  try:
101
129
  lines = env_path.read_text().splitlines()
102
130
  except OSError:
103
- return imported
131
+ return pairs
104
132
  for line in lines:
105
133
  line = line.strip()
106
134
  if not line or line.startswith("#") or "=" not in line:
@@ -109,17 +137,32 @@ def load_dotenv(env_path: Path, allow=allowed_env_key) -> List[str]:
109
137
  line = line[len("export "):].lstrip()
110
138
  key, _, value = line.partition("=")
111
139
  key = key.strip()
112
- if not key or not allow(key):
140
+ if not key:
113
141
  continue
114
142
  value = value.strip()
115
143
  if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
116
144
  value = value[1:-1]
145
+ pairs.append((key, value))
146
+ return pairs
147
+
148
+
149
+ def load_dotenv(env_path: Path, allow=allowed_env_key) -> List[str]:
150
+ """Import allowed ``KEY=VALUE`` lines into ``os.environ`` (never overriding); return imported keys."""
151
+ imported: List[str] = []
152
+ for key, value in parse_dotenv(env_path):
153
+ if not allow(key):
154
+ continue
117
155
  if key not in os.environ:
118
156
  os.environ[key] = value
119
157
  imported.append(key)
120
158
  return imported
121
159
 
122
160
 
161
+ def rejected_project_keys(env_path: Path) -> List[str]:
162
+ """Keys in a project ``.env`` that scoot knows but will not take from a repository."""
163
+ return [k for k, _ in parse_dotenv(env_path) if allowed_env_key(k) and not project_env_key_allowed(k)]
164
+
165
+
123
166
  def _as_bool(value: str) -> bool:
124
167
  return value.strip().lower() not in ("false", "0", "no", "off", "")
125
168
 
@@ -192,6 +235,7 @@ class Config:
192
235
  editor: str = "idea" # external editor for the open_editor tool: idea | vscode
193
236
  scope: str = "workspace" # where file tools may go: workspace (ask once outside it) | anywhere
194
237
  env_files: Tuple[str, ...] = () # the .env files that were read, in load order (shown by /status)
238
+ ignored_project_keys: Tuple[str, ...] = () # keys the project .env tried to set that only the user may set
195
239
  logo: bool = True # mascot in the launch banner + status-bar face (--no-logo / SCOOT_LOGO / /logo)
196
240
  emoji: bool = True # 🛴 transcript label; off → ⏺ for terminals without an emoji font (--no-emoji)
197
241
 
@@ -201,8 +245,14 @@ class Config:
201
245
  """Load config from the ``.env`` files + environment variables, falling back to defaults."""
202
246
  cwd = (cwd or Path.cwd()).resolve()
203
247
  files = env_files(cwd)
248
+ project = find_project_env(cwd)
249
+ ignored: List[str] = []
204
250
  for env_file in files:
205
- load_dotenv(env_file)
251
+ if env_file == project:
252
+ load_dotenv(env_file, allow=project_env_key_allowed)
253
+ ignored = rejected_project_keys(env_file)
254
+ else:
255
+ load_dotenv(env_file)
206
256
 
207
257
  get = os.environ.get
208
258
  root = Path(get("SCOOT_ROOT", str(cwd))).resolve()
@@ -210,6 +260,7 @@ class Config:
210
260
  return cls(
211
261
  proxy=get("HTTPS_PROXY") or get("https_proxy") or DEFAULT_PROXY,
212
262
  env_files=tuple(str(f) for f in files),
263
+ ignored_project_keys=tuple(ignored),
213
264
  provider=get("SCOOT_PROVIDER", "").strip().lower(),
214
265
  model=model,
215
266
  model_source=model_source,
@@ -124,8 +124,9 @@ class HeadlessUI:
124
124
  msg = self.answers.get(timeout=min(0.2, remaining))
125
125
  except queue.Empty:
126
126
  continue
127
- if msg.get("id") not in (None, "", req):
128
- self.writer.emit("error", message=f"approval answer for unknown request {msg.get('id')}", kind="protocol")
127
+ if msg.get("id") != req: # an unbound answer must never approve whatever comes next
128
+ self.writer.emit("error", message=f"approval answer must carry id {req!r}, got {msg.get('id')!r}",
129
+ kind="protocol")
129
130
  continue
130
131
  decision = _DECISIONS.get(str(msg.get("decision", "")).lower())
131
132
  if decision is None:
@@ -153,8 +154,9 @@ class HeadlessUI:
153
154
  msg = self.answers.get(timeout=min(0.2, remaining))
154
155
  except queue.Empty:
155
156
  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")
157
+ if msg.get("id") != req:
158
+ self.writer.emit("error", message=f"scope answer must carry id {req!r}, got {msg.get('id')!r}",
159
+ kind="protocol")
158
160
  continue
159
161
  verdict = mapping.get(str(msg.get("decision", "")).lower())
160
162
  if verdict is None:
@@ -229,12 +231,14 @@ class Headless:
229
231
  # ── main loop ────────────────────────────────────────────────────────────────
230
232
  def run(self) -> int:
231
233
  from . import tools
232
- from .hooks import session_event
234
+ from .hooks import session_event, startup_notices
233
235
 
234
236
  tools.load_builtins()
235
237
  threading.Thread(target=self._reader, daemon=True).start()
236
238
  if self.heartbeat_s > 0:
237
239
  threading.Thread(target=self._heartbeat, daemon=True).start()
240
+ for note in startup_notices(self.session):
241
+ self.writer.emit("notice", message=note)
238
242
  session_event(self.session, "SessionStart", source="resume" if getattr(self.session, "resumed", False) else "startup")
239
243
  self.writer.emit("ready", protocol=PROTOCOL, version=__version__, session_id=self.session.id,
240
244
  model=self.session.active_model, cwd=str(self.session.config.root),
@@ -16,7 +16,11 @@ A hook answers with its exit code or with JSON on stdout:
16
16
  * exit 2: block or deny, stderr is the reason
17
17
  * anything else, or a timeout: logged and ignored
18
18
  Hooks run sequentially in configuration order; the first blocking decision wins.
19
- ``SCOOT_HOOKS=0`` disables everything. Hooks never see API keys.
19
+ ``SCOOT_HOOKS=0`` disables everything. Provider API key variables are removed from a hook's
20
+ environment. A project's ``.scoot/hooks.json`` runs only after the user trusted it with
21
+ ``/hooks trust``; the trust is bound to the file's content, so an edited file asks again. A hook's
22
+ ``allow`` waives scoot's approval prompt; it waives the denylist confirmation only when the hook lives
23
+ in the user's global config (a machine-level delegate such as an editor or phone bridge), never a project.
20
24
  """
21
25
 
22
26
  from __future__ import annotations
@@ -48,6 +52,7 @@ class Decision:
48
52
  action: str = "" # "" | allow | deny | ask | block
49
53
  reason: str = ""
50
54
  context: str = "" # extra text a hook printed (UserPromptSubmit adds it to the prompt)
55
+ from_global: bool = False # the deciding hook is in the user's global config, not a project file
51
56
 
52
57
  @property
53
58
  def blocks(self) -> bool:
@@ -80,6 +85,62 @@ def project_path(root) -> Path:
80
85
  return Path(root) / ".scoot" / "hooks.json"
81
86
 
82
87
 
88
+ def file_digest(path: Path) -> Optional[str]:
89
+ """sha256 of the file's bytes, or ``None`` when it cannot be read."""
90
+ import hashlib
91
+
92
+ try:
93
+ return hashlib.sha256(path.read_bytes()).hexdigest()
94
+ except OSError:
95
+ return None
96
+
97
+
98
+ def project_trust(root) -> str:
99
+ """``"trusted"``, ``"untrusted"`` (a project file exists and is not trusted, or changed since),
100
+ or ``"none"`` (no project hooks file)."""
101
+ path = project_path(root)
102
+ if not path.is_file():
103
+ return "none"
104
+ from .preferences import trusted_hooks_digest
105
+
106
+ digest = file_digest(path)
107
+ return "trusted" if digest and digest == trusted_hooks_digest(root) else "untrusted"
108
+
109
+
110
+ def trust_project(root) -> bool:
111
+ """Record the current project hooks file as trusted. False when there is no such file."""
112
+ path = project_path(root)
113
+ digest = file_digest(path) if path.is_file() else None
114
+ if not digest:
115
+ return False
116
+ from .preferences import trust_hooks
117
+
118
+ trust_hooks(root, digest)
119
+ return True
120
+
121
+
122
+ def untrust_project(root) -> bool:
123
+ from .preferences import untrust_hooks
124
+
125
+ return untrust_hooks(root)
126
+
127
+
128
+ def _hook_env(payload: dict, event: str) -> dict:
129
+ """The child's environment: the process environment minus every provider key variable."""
130
+ env = dict(os.environ)
131
+ try:
132
+ from .providers import registry
133
+
134
+ for spec in registry.all_specs():
135
+ for key in spec.key_env:
136
+ env.pop(key, None)
137
+ except Exception:
138
+ pass
139
+ env["SCOOT_SESSION_ID"] = str(payload.get("session_id", ""))
140
+ env["SCOOT_HOOK_EVENT"] = event
141
+ return env
142
+
143
+
83
144
  def _load_file(path: Path) -> Dict[str, list]:
84
145
  try:
85
146
  data = json.loads(path.read_text())
@@ -129,13 +190,28 @@ class Hooks:
129
190
  self.reload()
130
191
 
131
192
  def reload(self) -> None:
193
+ """Read the global file, and the project file only when the user trusted this exact content.
194
+
195
+ An untrusted project file is remembered in ``self.untrusted`` so the front ends can say so.
196
+ """
132
197
  self.config = {}
133
198
  self.sources = []
134
- for path in (project_path(self.root), global_path()):
199
+ self.untrusted: Optional[Path] = None
200
+ self.trust = project_trust(self.root)
201
+ paths = [global_path()]
202
+ if self.trust == "trusted":
203
+ paths.insert(0, project_path(self.root))
204
+ elif self.trust == "untrusted":
205
+ self.untrusted = project_path(self.root)
206
+ gpath = global_path()
207
+ for path in paths:
135
208
  if path.is_file():
136
209
  loaded = _load_file(path)
137
210
  self.sources.append(str(path))
211
+ origin = "global" if path == gpath else "project"
138
212
  for event, entries in loaded.items():
213
+ for entry in entries:
214
+ entry["_source"] = origin # so a decision can say whether it came from global config
139
215
  self.config.setdefault(event, []).extend(entries)
140
216
 
141
217
  def has(self, event: str) -> bool:
@@ -160,10 +236,14 @@ class Hooks:
160
236
 
161
237
  # ── running ──────────────────────────────────────────────────────────────────
162
238
  def run(self, event: str, payload: dict, cancel_event: Optional[threading.Event] = None) -> Decision:
239
+ """Run every applicable hook and combine their answers: a deny or block wins over an ask,
240
+ an ask wins over an allow. An early ``allow`` from one file can no longer hide a ``deny``
241
+ from another; the first answer at the winning level supplies the reason."""
163
242
  if not self.has(event):
164
243
  return Decision()
165
244
  tool_name = str(payload.get("tool_name", ""))
166
245
  context: List[str] = []
246
+ decisions: List[Decision] = []
167
247
  for entry in self.config.get(event, []):
168
248
  matcher = entry.get("matcher")
169
249
  if matcher and event in ("PreToolUse", "PostToolUse"):
@@ -180,6 +260,13 @@ class Hooks:
180
260
  if decision.context:
181
261
  context.append(decision.context)
182
262
  if decision.action:
263
+ decision.from_global = entry.get("_source") == "global"
264
+ decisions.append(decision)
265
+ if cancel_event is not None and cancel_event.is_set():
266
+ break
267
+ for level in (("deny", "block"), ("ask",), ("allow",)):
268
+ for decision in decisions:
269
+ if decision.action in level:
183
270
  decision.context = "\n".join(context)
184
271
  return decision
185
272
  return Decision(context="\n".join(context))
@@ -188,9 +275,7 @@ class Hooks:
188
275
  from .errors import Interrupted
189
276
  from .tools.base import ToolError, run_subprocess
190
277
 
191
- env = dict(os.environ)
192
- env["SCOOT_SESSION_ID"] = str(payload.get("session_id", ""))
193
- env["SCOOT_HOOK_EVENT"] = event
278
+ env = _hook_env(payload, event)
194
279
  started = time.time()
195
280
  try:
196
281
  # The same runner as the tools: its own process group, bounded waits, ESC honoured.
@@ -269,6 +354,22 @@ def for_session(session) -> Optional[Hooks]:
269
354
  return getattr(session, "hooks", None) if enabled() else None
270
355
 
271
356
 
357
+ def startup_notices(session) -> List[str]:
358
+ """What a front end should tell the user once at start: an untrusted project hooks file, and
359
+ project ``.env`` keys that were ignored because only the user may set them."""
360
+ notes: List[str] = []
361
+ hooks = getattr(session, "hooks", None)
362
+ if hooks is not None and getattr(hooks, "untrusted", None) is not None:
363
+ notes.append(f"this project has hooks in {hooks.untrusted} that are not trusted, so they will not run; "
364
+ f"review the file, then /hooks trust")
365
+ cfg = getattr(session, "config", None)
366
+ ignored = tuple(getattr(cfg, "ignored_project_keys", ()) or ())
367
+ if ignored:
368
+ notes.append(f"ignored from the project .env (only you may set these, in ~/.config/scoot/.env or the "
369
+ f"environment): {', '.join(ignored)}")
370
+ return notes
371
+
372
+
272
373
  def submit_prompt(session, text: str, cancel_event=None) -> Optional[str]:
273
374
  """Fire ``UserPromptSubmit``. Returns the (possibly extended) prompt, or ``None`` when blocked."""
274
375
  hooks = for_session(session)
@@ -157,11 +157,12 @@ def _sniff_mime(path: Path, raw: bytes) -> str:
157
157
  def to_data_uri(path: Path, max_bytes: int = DEFAULT_MAX_BYTES) -> EncodedImage:
158
158
  """Read + base64-encode an image into a ``data:`` URI. Raises :class:`ImageTooLargeError` over cap."""
159
159
  path = Path(path)
160
- raw = path.read_bytes()
161
- if max_bytes and len(raw) > max_bytes:
160
+ size = path.stat().st_size # check before reading, so an oversized file is never loaded
161
+ if max_bytes and size > max_bytes:
162
162
  raise ImageTooLargeError(
163
- f"{path.name} is {len(raw) // 1024} KB, over the {max_bytes // 1024} KB limit"
163
+ f"{path.name} is {size // 1024} KB, over the {max_bytes // 1024} KB limit"
164
164
  )
165
+ raw = path.read_bytes()
165
166
  mime = _sniff_mime(path, raw)
166
167
  b64 = base64.b64encode(raw).decode("ascii")
167
168
  return EncodedImage(name=path.name, mime=mime, data_uri=f"data:{mime};base64,{b64}", size=len(raw))
@@ -62,19 +62,21 @@ def query_cursor(timeout: float = 0.2) -> Optional[Tuple[int, int]]:
62
62
  return None
63
63
 
64
64
 
65
- def read_key() -> str:
66
- """Read a single keypress (lowercased). Falls back to a line read on non-TTY (piped) input."""
65
+ def read_key(keep_case: bool = False) -> str:
66
+ """Read a single keypress, lowercased unless ``keep_case`` (a menu that tells ``a`` from ``A``).
67
+ Falls back to a line read on non-TTY (piped) input."""
67
68
  if not (_HAVE_TERMIOS and sys.stdin.isatty()):
68
69
  line = sys.stdin.readline()
69
- return (line.strip()[:1].lower() if line.strip() else "q")
70
- fd = sys.stdin.fileno()
71
- old = termios.tcgetattr(fd)
72
- try:
73
- tty.setcbreak(fd)
74
- ch = sys.stdin.read(1)
75
- finally:
76
- termios.tcsetattr(fd, termios.TCSADRAIN, old)
77
- return ch.lower()
70
+ ch = line.strip()[:1] if line.strip() else "q"
71
+ else:
72
+ fd = sys.stdin.fileno()
73
+ old = termios.tcgetattr(fd)
74
+ try:
75
+ tty.setcbreak(fd)
76
+ ch = sys.stdin.read(1)
77
+ finally:
78
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
79
+ return ch if keep_case else ch.lower()
78
80
 
79
81
 
80
82
  class InterruptibleSection: