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.
- {scootcli-0.9.0/src/scootcli.egg-info → scootcli-0.10.0}/PKG-INFO +8 -5
- {scootcli-0.9.0 → scootcli-0.10.0}/README.md +7 -4
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/agent.py +12 -6
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/approvals.py +16 -4
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/cli.py +8 -2
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/hooks.py +21 -5
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/config.py +57 -6
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/headless.py +9 -5
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/hooks.py +106 -5
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/images.py +4 -3
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/keys.py +13 -11
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/preferences.py +33 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/rendering.py +52 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/repl.py +12 -3
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/sessions.py +47 -22
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/read_file.py +7 -1
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/search.py +25 -4
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/workspace.py +4 -2
- {scootcli-0.9.0 → scootcli-0.10.0/src/scootcli.egg-info}/PKG-INFO +8 -5
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli.egg-info/SOURCES.txt +1 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_approvals.py +31 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_config.py +65 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_headless.py +28 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_hooks.py +212 -0
- scootcli-0.10.0/tests/test_rendering.py +53 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_sessions.py +64 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_tools.py +73 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/LICENSE +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/pyproject.toml +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/setup.cfg +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/auth.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/__init__.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/auth.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/model.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/scope.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/status.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/context.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/models.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/panel.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/pricing.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/project.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/prompts.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/providers/base.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/providers/registry.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/providers/router.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/resize.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/status.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/base.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/edit_file.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/open_editor.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/run_shell.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/tools/write_file.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/transport.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/updates.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli/worktree.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_agent.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_anthropic.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_auth.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_images.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_logo.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_models.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_panel.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_preferences.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_providers.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_readiness.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_repl.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_resilience.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_resize.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_router.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_scope.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_small_060.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_smoke.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_streaming.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_transport_native.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_vision.py +0 -0
- {scootcli-0.9.0 → scootcli-0.10.0}/tests/test_workspace.py +0 -0
- {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.
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
-
|
|
327
|
-
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
-
|
|
302
|
-
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
127
|
+
pairs: List[Tuple[str, str]] = []
|
|
100
128
|
try:
|
|
101
129
|
lines = env_path.read_text().splitlines()
|
|
102
130
|
except OSError:
|
|
103
|
-
return
|
|
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
|
|
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
|
-
|
|
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")
|
|
128
|
-
self.writer.emit("error", message=f"approval answer
|
|
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")
|
|
157
|
-
self.writer.emit("error", message=f"answer
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
161
|
-
if max_bytes and
|
|
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 {
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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:
|