1 Commits

Author SHA1 Message Date
NCBM 9961bdea6f release: plyngent 0.5.0 2026-09-08 13:14:21 +08:00
42 changed files with 248 additions and 1846 deletions
+7 -8
View File
@@ -66,7 +66,7 @@ MCP (`runtime/mcp_client.py`): `McpManager` owns one `McpServerConnection` per `
### Memory (`memory/`) ### Memory (`memory/`)
Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `ALTER` for new columns; `PRAGMA user_version` 4), default local user, sessions (bound to `workspace` path; optional `provider_name`/`model`; `todo_stack` + `access_grants` JSON columns), messages stored as msgspec chat message JSON. Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `ALTER` for new columns), default local user, sessions (bound to `workspace` path; optional `provider_name`/`model`), messages stored as msgspec chat message JSON.
### Agent (`agent/`) ### Agent (`agent/`)
@@ -80,25 +80,24 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first. - **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first.
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`). - Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`. - Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker. - Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
### Tools (`tools/`) ### Tools (`tools/`)
Module-level `@tool` handlers. Call `set_workspace_root()` before use. Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command basename denylist (CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error hints `request_directory_access`. - **`workspace`**: path resolve under primary root **or** temporary allowlist roots; path substring denylist; command basename denylist (CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); clearer deny messages.
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (`--yes`/`/yolo`, process only). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file`, `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`). - **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file`, `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep). - **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill. - PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget. The prompt accepts `y`/empty (once), `n`, or `yyy` = stop asking for the rest of the turn (`cli.limits` module flag, reset by `run_turn_with_retries`, `/btw`, session switches, and chat exit). `--auto-continue` / `[agent] auto_continue_limits` set the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa. - CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget.
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for shells/REPLs and `python|bash -c` one-liners (argv + code preview); deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn). - Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for shells/REPLs and `python|bash -c` one-liners (argv + code preview); deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems. - **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs). - **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs).
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`). - **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`).
- **`net` / `fetch`**: HTTP GET/POST/PUT/DELETE via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size. - **`net` / `fetch`**: HTTP GET/POST/PUT/DELETE via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size.
- **`mcp`** (`tools/mcp.py`): register one server's advertised tools into the catalog as namespaced `mcp__<server>__<tool>` definitions with `ToolSource(kind="mcp", plugin_id=server)`; tags LOCAL (+ READ_ONLY when the server config sets `read_only`) — never YOLO/TRUSTABLE. `register_mcp_tools` purges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started by `McpManager`. - **`mcp`** (`tools/mcp.py`): register one server's advertised tools into the catalog as namespaced `mcp__<server>__<tool>` definitions with `ToolSource(kind="mcp", plugin_id=server)`; tags LOCAL (+ READ_ONLY when the server config sets `read_only`) — never YOLO/TRUSTABLE. `register_mcp_tools` purges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started by `McpManager`.
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + net (`fetch`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `grep_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `fetch`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch` accept GET only. - **`DEFAULT_TOOLS` / catalog**: file + process + vcs + chat + todo + net (`fetch`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `grep_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `fetch`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch` accept GET only.
### Prompting (`prompting.py`) ### Prompting (`prompting.py`)
@@ -108,8 +107,8 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`. Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--auto-continue` (auto-raise limits), `--stream/--no-stream`, `--quiet`. Root `--log-level`. Enabled `[mcp]` servers start before the REPL (`_start_mcp_manager`, per-server failures warn on stderr) and are closed on exit (`McpManager.aclose`). - **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`. Enabled `[mcp]` servers start before the REPL (`_start_mcp_manager`, per-server failures warn on stderr) and are closed on exit (`McpManager.aclose`).
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry. `/grants` lists directory-access grants (`revoke <index|all>`; config grants point at the TOML); `/status` includes the grant count. - Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry.
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors. - `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors.
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. - Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**.
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`. - Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`.
+2 -11
View File
@@ -150,10 +150,6 @@ max_context_tokens = 200000
# Fold MCP server initialize ``instructions`` (usage guidance) into the # Fold MCP server initialize ``instructions`` (usage guidance) into the
# system prompt when tools are on (default true; false ignores server text). # system prompt when tools are on (default true; false ignores server text).
# mcp_instructions = true # mcp_instructions = true
# Static out-of-workspace pre-allow (path → read|write|exec); never prompts.
# allow_paths = { "/data/datasets" = "read", "/tmp/build" = "exec" }
# Auto-raise tool/PTY limits without prompting (same as answering yyy).
# auto_continue_limits = false
# Optional plugins (entry-point names); default load none. See doc/plugins.md. # Optional plugins (entry-point names); default load none. See doc/plugins.md.
# [plugins] # [plugins]
@@ -218,13 +214,10 @@ plyngent chat --session 3
| `--stream` / `--no-stream` | Streaming deltas (default on) | | `--stream` / `--no-stream` | Streaming deltas (default on) |
| `--quiet` | Less status on stderr | | `--quiet` | Less status on stderr |
| `--yes` | YOLO on: skip destructive-tool confirms for this process | | `--yes` | YOLO on: skip destructive-tool confirms for this process |
| `--auto-continue` | Auto-raise tool/PTY limits without prompting (like answering `yyy`) |
| `--log-level` | On the root CLI: `DEBUG`, `INFO`, `WARNING`, … | | `--log-level` | On the root CLI: `DEBUG`, `INFO`, `WARNING`, … |
Sessions resume the **most recently updated** session for the current workspace unless you pass `--new` or `--session`. Each session remembers the last **provider** and **model** (restored on resume so you are not re-prompted). Sessions resume the **most recently updated** session for the current workspace unless you pass `--new` or `--session`. Each session remembers the last **provider** and **model** (restored on resume so you are not re-prompted).
When a tool-loop or PTY limit is hit, the prompt accepts `y` (continue once), `n` (stop), or `yyy` — **stop asking for the rest of this turn** (the next user turn prompts again). `--auto-continue` / `[agent] auto_continue_limits = true` skip the prompt for every turn.
### One-shot (scripts / CI) ### One-shot (scripts / CI)
```bash ```bash
@@ -270,7 +263,6 @@ Type `/help` in the REPL for the live list. Common ones:
| `/models` | List config + remote `GET /models` (always re-fetches) | | `/models` | List config + remote `GET /models` (always re-fetches) |
| `/models --persist` | Merge remote catalog into TOML for this provider | | `/models --persist` | Merge remote catalog into TOML for this provider |
| `/todos` | Todo/task stack: list, push, pop, done, clear | | `/todos` | Todo/task stack: list, push, pop, done, clear |
| `/grants` | Directory-access grants: list, or `revoke <index\|all>` |
| `/config` | Edit `plyngent.toml` ($VISUAL/$EDITOR or system open); reload after blocking editor | | `/config` | Edit `plyngent.toml` ($VISUAL/$EDITOR or system open); reload after blocking editor |
| `/quit` | Leave the REPL | | `/quit` | Leave the REPL |
@@ -280,16 +272,15 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
- **Workspace** = root for file/process/VCS tools (default cwd). - **Workspace** = root for file/process/VCS tools (default cwd).
- **Session** = SQLite chat bound to a workspace path. - **Session** = SQLite chat bound to a workspace path.
- Paths outside the workspace need a **directory-access grant**: the model calls `request_directory_access` (`read` / `write` / `exec`), the human approves at the requested level or another one (timed y/N, default deny; `--yes`/`/yolo` auto-approve as a process-only grant), and approval lasts for the session (`/grants` lists/revokes). Static pre-allow lives under `[agent].allow_paths`. The path denylist always wins, and grants gate path-resolving tools only — they are **not** a sandbox (`run_argv`/PTY reach the whole filesystem).
- Resuming a session from another directory prompts: keep session workspace, rebind to current, or abort. - Resuming a session from another directory prompts: keep session workspace, rebind to current, or abort.
## Tools (when enabled) ## Tools (when enabled)
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `request_directory_access` (out-of-workspace access grants), `get_truncated` (resume any truncated result via its `truncate_token=...`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`). Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `get_truncated` (resume any truncated result via its `truncate_token=...`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
Safety defaults: Safety defaults:
- Paths stay under the workspace unless granted (`request_directory_access` / `[agent].allow_paths`); optional `path_denylist` substrings always apply (`tree` also skips denylisted children by default). - Paths stay under the workspace; optional `path_denylist` substrings (`tree` also skips denylisted children by default).
- Command basename denylist (e.g. dangerous shells/utilities). - Command basename denylist (e.g. dangerous shells/utilities).
- Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). Override for the session with `/yolo on|off|once` or startup `--yes` (path/command denylists still apply). - Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). Override for the session with `/yolo on|off|once` or startup `--yes` (path/command denylists still apply).
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit. - PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
+2 -2
View File
@@ -27,8 +27,8 @@
- agent: Kind-based tool loop, streaming, usage, compact; bridges: - agent: Kind-based tool loop, streaming, usage, compact; bridges:
``responses_bridge``/``responses_dispatch``, ``messages_bridge``/``messages_dispatch``; ``responses_bridge``/``responses_dispatch``, ``messages_bridge``/``messages_dispatch``;
``@tool`` / tags / registry. History stays chat-completions-shaped. ``@tool`` / tags / registry. History stays chat-completions-shaped.
- tools: Workspace file/process/VCS/chat/todo/net tools + directory-access - tools: Workspace file/process/VCS/chat/todo/net tools; catalog; plugins;
grants (`access`); catalog; plugins; instance/session context and views. instance/session context and views.
- prompting: Shared ask/choose/form for CLI and tools. - prompting: Shared ask/choose/form for CLI and tools.
- cli: Click entry, slash registry, REPL, one-shot chat. - cli: Click entry, slash registry, REPL, one-shot chat.
- web: Web service (Phase H; not implemented). - web: Web service (Phase H; not implemented).
-7
View File
@@ -58,13 +58,6 @@ max_tool_result_chars = 32000
parallel_tools = true parallel_tools = true
confirm_destructive = true confirm_destructive = true
path_denylist = ["/secrets/", ".ssh/", ".gnupg/"] path_denylist = ["/secrets/", ".ssh/", ".gnupg/"]
# Static out-of-workspace pre-allow (path → read|write|exec); never prompts.
# The model can also request access at runtime via request_directory_access
# (human approves; /grants lists or revokes). Denylist still applies, and
# grants are a tool-policy gate, not a sandbox (commands reach the FS).
# allow_paths = { "/data/datasets" = "read", "/tmp/build" = "exec" }
# Auto-raise tool/PTY limits without prompting (same as answering yyy).
# auto_continue_limits = false
# Soft context budget in estimated tokens (~4 chars/token when API usage missing). # Soft context budget in estimated tokens (~4 chars/token when API usage missing).
max_context_tokens = 200000 max_context_tokens = 200000
# Fold connected MCP servers' initialize ``instructions`` (usage guidance) # Fold connected MCP servers' initialize ``instructions`` (usage guidance)
Generated
+151 -201
View File
@@ -4,7 +4,7 @@
[metadata] [metadata]
groups = ["default", "dev"] groups = ["default", "dev"]
strategy = ["inherit_metadata"] strategy = ["inherit_metadata"]
lock_version = "4.5.1" lock_version = "4.5.0"
content_hash = "sha256:94dc16bf6ed12e2fcd7a109b500e9b2d7e39f224b60472d64ecb1d3436ddbb82" content_hash = "sha256:94dc16bf6ed12e2fcd7a109b500e9b2d7e39f224b60472d64ecb1d3436ddbb82"
[[metadata.targets]] [[metadata.targets]]
@@ -52,109 +52,53 @@ files = [
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.5.1" version = "3.4.9"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
groups = ["default"] groups = ["default"]
files = [ files = [
{file = "charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8"}, {file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"},
{file = "charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102"}, {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"},
{file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5"}, {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"},
{file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3"}, {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"},
{file = "charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c"}, {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0"}, {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96"}, {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc"}, {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f"}, {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90"}, {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506"}, {file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5"}, {file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e"}, {file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5"}, {file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3"}, {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3"}, {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36"}, {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"},
{file = "charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7"}, {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b"}, {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b"}, {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687"}, {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348"}, {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef"}, {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885"}, {file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375"}, {file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1"}, {file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65"}, {file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5"}, {file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f"},
{file = "charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a"},
{file = "charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e"},
{file = "charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6"},
{file = "charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3"},
] ]
[[package]] [[package]]
name = "click" name = "click"
version = "8.5.0" version = "8.4.2"
requires_python = ">=3.10" requires_python = ">=3.10"
summary = "Composable command line interface toolkit" summary = "Composable command line interface toolkit"
groups = ["default"] groups = ["default"]
dependencies = [
"colorama; platform_system == \"Windows\"",
]
files = [ files = [
{file = "click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360"}, {file = "click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"},
{file = "click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34"}, {file = "click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6"},
] ]
[[package]] [[package]]
@@ -162,8 +106,8 @@ name = "colorama"
version = "0.4.6" version = "0.4.6"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
summary = "Cross-platform colored terminal text." summary = "Cross-platform colored terminal text."
groups = ["dev"] groups = ["default", "dev"]
marker = "sys_platform == \"win32\"" marker = "platform_system == \"Windows\" or sys_platform == \"win32\""
files = [ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@@ -242,43 +186,43 @@ files = [
[[package]] [[package]]
name = "jh2" name = "jh2"
version = "5.0.14" version = "5.0.13"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "HTTP/2 State-Machine based protocol implementation" summary = "HTTP/2 State-Machine based protocol implementation"
groups = ["default"] groups = ["default"]
files = [ files = [
{file = "jh2-5.0.14-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a1ee369128cee3a78be7ade531e7229480c22b20b2371671cfe566f34b47e079"}, {file = "jh2-5.0.13-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:1dd137086b13d55a53c4b7b8b75869c824060a4d35615e4f49180ae58c7783f5"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cfbad7863fcb8dc66dbc9da3e9ccfe11f8944d043618d486fdfd12b1ab54f6a"}, {file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75d0b2bffb2e260a0f3578a11a4999a713c94d1bd8de7cfe692c840e20ae83cf"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9087585bd1f8c11fbade60164b9e79f42323784b22be245ceaeca758422e6bc5"}, {file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f681e197bf6552df3bb7a3abfd676e81316b69e36e7263e72383477174032ca2"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ccbfa14b306d5c5a4a68f3fe9823d686e46406c7dba5babcccb198fd5ae53a11"}, {file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bde7e7ff0f17e87aca9ef23f66b80540a2de0ff8e2c26af55eb2e288a2a5f74f"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5480d38a0eeb43f2bce716aaddee2b89fe30e24b9ac5b7ecfb8cbc7fb822c257"}, {file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f8d364d53cc0854d910f2305726a8254acb527f13fa933dc213a591e21e7ffa"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:348824676428d44d6ddceadb2c1d0885cc351ebe0378eadfa74e382c5359abbe"}, {file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b572daaafbd867bb5c5f324c918704ddd6d76af1cc7c2be483c3649b76ce7940"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e09fb3bb222ce3cce63bcfab99cb48cbfdc44f56e81369bb1f920ef02c1105f"}, {file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0772ad66f346038fe05cb2928866ff1b21bcaa9ec3f14709fa1d419ed1dc287"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:20f9b15a367961dbbedeb0f40380668141d3543f427246456f337a6e9fbb5ab2"}, {file = "jh2-5.0.13-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1d8313b13acfdef19c5ff793fdf397bae3d8d75181f6eee62604f13e4ec20a8"},
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3f60ba14d122c0c72b0da283c69e1c0e717fe18c32b8fbd13202954d5d5c7f4b"}, {file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:99aedd20daacb32f377152c9103d8d0271f32d1932bd39a5ed2cd2eef97f7e07"},
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:9e168d952ac659837c758e206efc7aef2a8b4eb57389691a2675f9059bdbb7d7"}, {file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:53b1df29ffe966cde1d2f0085b6acfcd237504627d406a51717ef3849ec88831"},
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:dedaf4cc07aee718c4380fcb0bea231f205be6db45a0855454b1d467a0c86837"}, {file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:0ee6a5dd24a1dc0772a89b3498ed20f60167fbc633404b1e02895f04ae66c47c"},
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:9fc37f6fbeaba1fd5dd686594655448e2c98fbbb87b2a387faf5a331b2bc85b6"}, {file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:521f871864880cabe361060f070516a56a54abc3e96f07bee9ba8b061eada891"},
{file = "jh2-5.0.14-cp314-cp314t-win32.whl", hash = "sha256:8b4d38bd4bfb3cd2fd0eec62e95e14efd3c97b50535216acbc30afea6b5963e1"}, {file = "jh2-5.0.13-cp314-cp314t-win32.whl", hash = "sha256:5909e2273f8b6fcea93c95564aa0f366ff9261a544a7a5d640ea95341321cc37"},
{file = "jh2-5.0.14-cp314-cp314t-win_amd64.whl", hash = "sha256:56c3e595254d253657a640bb1f92711836676a654a64484f6b1f1112ec52e68a"}, {file = "jh2-5.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:2cc4923ac6f57753e0cb4aef054a04f5d5c744b274dabf863f695fa3c2fd75f9"},
{file = "jh2-5.0.14-cp314-cp314t-win_arm64.whl", hash = "sha256:391a5357ac82a27d72579ccb9bcb281fd58da0c76e702158416ecabaa65fc3e2"}, {file = "jh2-5.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:d3cf203de31371289d4c1e6b4d30c9f6de95b2bd2b300fe5468ff946299d0e48"},
{file = "jh2-5.0.14-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:68a23b2b8c1750b8bd208d7e5be0fce20f96aca795f57c56e525a36470773625"}, {file = "jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3f54095cde010ff4e52f23dda92b31d3d3160c64f5f23fc6e181f8c7938afd33"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0c2a4501c2a8ae3db2228c9138dc4a4b047750ffab018c29fbf58de59334bd8"}, {file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f810dd02b4a9647eacbe4eab617111c0e3ffc68e923e3ff172948db601d2ba1e"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c32cc7136c54a63fc04abbdb656cd37dcd6b8462b946cd072ebdfbcf9350be45"}, {file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29c23f3937f257beb5cd5b17f8727404f1267ff25d26e54c0a35d5defd0c895e"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c9f39c49b4b02b82202fe9b983f22f1f2aa685ae96ee5800a9b4cad511c07b68"}, {file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c697b250a935030a128b45b41f507a4d33a082d8b8e12311c7e73fbb3970f057"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20517ff67db8ac5c3af0bafffe6a639721d8be32f7a9a7c2b25f0ad7f74d2c85"}, {file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47d3203ed6fec39df4922764817979fad3a13d22cf750adc9cf9dc2b3399eacf"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4df556fe49b596774af0f49f78eff1e143448f8367609d3e6d2a50533ada3855"}, {file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5146468a76bbe8f4035f23a78813c5c433c70c0ca44eb0ba5558627a9e644b8"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3e92cd9d01351d14b15c826120bfb41f5834bb0a8860cbaa1779f557d3e1288"}, {file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d3d338284fbfbe173243c3cc62e510c6fa0d6d023e67da2292bd7578c734e3"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a6eeb6cff0f5fcd529d7e0afc75eb81ad5befd5b6b893c20f188fb7f467a0320"}, {file = "jh2-5.0.13-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:641f65f5414b8990251bd7609513a696831558f69d2ca77d65dc3f7087f31267"},
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:50ef70513db51c8a078e09b0bd824b0a9e3ffd92ee20122c4fa128d3a33b3625"}, {file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:cf3dc6f7c14340241260bf63e5d6006b2ee7db45dafcb39c16fdf164ea0240a0"},
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:52d105593e3af3384119f398682ed29fcbf79c9e87d637f8f49baaeb10adc0d4"}, {file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:fb8e37ecf37382c02af83da521df5a77561441bc4cbb93b00627e015b7bbf349"},
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:bda792840b62a41bf24049849a38b9355096f1a03a82cc7bc5309e6e85789b1c"}, {file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:296ee44dfad08ccc3857daf433dfa4a555d5b45f2df39503ccdd90ff10b32943"},
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:fddbfe1dd7c55160431975863b415b124ba408c20a8dab6ff3f2bf1eb18c3f8e"}, {file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1a75eed545420a31ea356e8046b8d86fcf694234dd1035191734a03720fb8a44"},
{file = "jh2-5.0.14-cp37-abi3-win32.whl", hash = "sha256:c41cfd834a360e3eb7b1e3d18b9d1debb16e001e08177d422f65293bd1e586aa"}, {file = "jh2-5.0.13-cp37-abi3-win32.whl", hash = "sha256:4812710810010c428db04e9290a13117f7b7c5a3a663b46a0586fe101cf25c9d"},
{file = "jh2-5.0.14-cp37-abi3-win_amd64.whl", hash = "sha256:1008bd1c05b879e33a9450823a67066716dd430f96a872dc652266c2271d7615"}, {file = "jh2-5.0.13-cp37-abi3-win_amd64.whl", hash = "sha256:5d61bc0a62b4eee11039bf29d4bdbb26efcff760dfb9d8e0739740028aa52b0f"},
{file = "jh2-5.0.14-cp37-abi3-win_arm64.whl", hash = "sha256:b5c82c7045e83e89e4b59d2940c456365bd7ad7e49650ae5a977cacdd4a2354d"}, {file = "jh2-5.0.13-cp37-abi3-win_arm64.whl", hash = "sha256:3f74dad9036e599eed51576492b00955237b86cf886f96844708a81cd0f5c710"},
{file = "jh2-5.0.14-py3-none-any.whl", hash = "sha256:070895dfaeda94eccbdc45c7c97985d1c6ac339de7c4e329106b6f9450189bda"}, {file = "jh2-5.0.13-py3-none-any.whl", hash = "sha256:2b311414989da48a155721fe47d0dc3e3b75360efd62d779aaff72d786be91cf"},
{file = "jh2-5.0.14.tar.gz", hash = "sha256:101708db41998159295c403f891dce9d8055a8dd98450c605e6eb7c34751543e"}, {file = "jh2-5.0.13.tar.gz", hash = "sha256:f8c78cffb3a35c4410513c3eb7989de36028c84277c04f07c97909dd94c23a75"},
] ]
[[package]] [[package]]
@@ -334,7 +278,7 @@ files = [
[[package]] [[package]]
name = "niquests" name = "niquests"
version = "3.21.1" version = "3.21.0"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "Niquests is a simple, yet elegant, HTTP library. It is a drop-in replacement for Requests, which is under feature freeze." summary = "Niquests is a simple, yet elegant, HTTP library. It is a drop-in replacement for Requests, which is under feature freeze."
groups = ["default"] groups = ["default"]
@@ -344,13 +288,13 @@ dependencies = [
"wassima<3,>=1.0.1; sys_platform != \"emscripten\"", "wassima<3,>=1.0.1; sys_platform != \"emscripten\"",
] ]
files = [ files = [
{file = "niquests-3.21.1-py3-none-any.whl", hash = "sha256:ac1ff8061cb480cdaad07f08b95cf21ee0a68ef21fbc2b6ddf5afff8e0d0072e"}, {file = "niquests-3.21.0-py3-none-any.whl", hash = "sha256:dbea441b9e9d5d755e02caa2b57b94cdb97b23d754ead21b80a4ac18ef66805f"},
{file = "niquests-3.21.1.tar.gz", hash = "sha256:8ba6ee8712570ae41dfc59ac2ac7e509dcde660c6cc47078b43be423a2f99e64"}, {file = "niquests-3.21.0.tar.gz", hash = "sha256:5b7d10a05f4c7ed08cede0af74f492ae7a8a5a71291833d029e23365fc3ea80a"},
] ]
[[package]] [[package]]
name = "nodejs-wheel-binaries" name = "nodejs-wheel-binaries"
version = "24.19.0" version = "24.16.0"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "unoffical Node.js package" summary = "unoffical Node.js package"
groups = ["dev"] groups = ["dev"]
@@ -358,15 +302,15 @@ dependencies = [
"typing-extensions; python_version < \"3.8\"", "typing-extensions; python_version < \"3.8\"",
] ]
files = [ files = [
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:e12cbfd69089504e42fb14194ce734a9dcf3eb38c820ca63dd511d36fb964e9c"}, {file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:1c890adf4b7e6556ccc1ca66c866bb81884b6c9a581dee4e530dc7f78fb9d514"}, {file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4e029dadfae1295876063c96b236f673487e8c27379fe146c1e2250283520227"}, {file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4196a947bcc883f2003ab101762d729f3e99b5e86b75bd09151563403e2eceb8"}, {file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:352e048ab4dd35e7de5f338d1cc4fcbf77a0e93da30bf7336a8217ee246b31d7"}, {file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:28d078b2ced9e2069516e652dc4b1380e7a1a7f2d3934eccd1611586d283ba4c"}, {file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:67e3abeb9c3830cae8c8487ae8a2af7cc27dfa75af06145cee5ca7d1857c81bd"}, {file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36"}, {file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36"},
{file = "nodejs_wheel_binaries-24.19.0.tar.gz", hash = "sha256:db217eef8cab8551667863379b08db4d9067403f6cbbe87481eb40edceb8aa9b"}, {file = "nodejs_wheel_binaries-24.16.0.tar.gz", hash = "sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37"},
] ]
[[package]] [[package]]
@@ -382,13 +326,13 @@ files = [
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.11.7" version = "4.11.2"
requires_python = ">=3.10" requires_python = ">=3.10"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["default"] groups = ["default"]
files = [ files = [
{file = "platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6"}, {file = "platformdirs-4.11.2-py3-none-any.whl", hash = "sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4"},
{file = "platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d"}, {file = "platformdirs-4.11.2.tar.gz", hash = "sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d"},
] ]
[[package]] [[package]]
@@ -404,13 +348,13 @@ files = [
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.21.0" version = "2.20.0"
requires_python = ">=3.9" requires_python = ">=3.9"
summary = "Pygments is a syntax highlighting package written in Python." summary = "Pygments is a syntax highlighting package written in Python."
groups = ["default", "dev"] groups = ["default", "dev"]
files = [ files = [
{file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"}, {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
] ]
[[package]] [[package]]
@@ -466,47 +410,47 @@ files = [
[[package]] [[package]]
name = "qh3" name = "qh3"
version = "2.0.3" version = "1.9.4"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "A lightweight and fast implementation of QUIC and HTTP/3" summary = "A lightway and fast implementation of QUIC and HTTP/3"
groups = ["default"] groups = ["default"]
marker = "(platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"" marker = "(platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\""
files = [ files = [
{file = "qh3-2.0.3-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:db98ea31b7486cac5dffc76c15b34c08208b219379aa9d274df21b1d47c7dbcb"}, {file = "qh3-1.9.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:100368855b0e103774438aaf8b38c711fb51b79f18e3b9dc873ea573648bcc3d"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bb0a8f54275e0ecb722c0a6cebeff73771b31da0dbfaed8e4b72efcbe98e2ab"}, {file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b9d45e544e0a556380bd2a92af180ee98d32bca6cf622f28350ebc000611bdf"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e466a285b8f598ea06a296910a817571f132bb3ff3f5a26cdc905ffe6d9d743c"}, {file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43f80d662078c26563a1aac32a4df22649473d996bf2422e073c8aa55b1c837c"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a266a68f27a86d6d49978e1c6b8e9e5dd514e018a042dafca48cbeb50a29d0d3"}, {file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:551f60d487d849d6f2313dda94e54ebb16b1a29acb93676540ae790a56308c24"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2646d05d20e7171d928d9b478097743d0d2a5b3893f8caddd7984b2825d90daa"}, {file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3755d777ff35561bc719f571ebc4b91aaa8a945c210e50b2576bf2cc69cb511f"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ccac4672539eb2897c3beaa73b626460040adf94c30edbd420db3988bd08e0f1"}, {file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04b64bb18e10d8f482bea1ef43e5da3448e2d5ba78bb0c735da5b9e02d7dfb0e"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0e57b96af05653ddcc4797fe761918d5d0a25ed90a9176260333f48e7b88f72"}, {file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b47597daa4ae9195f81a35822217a395d3277f01a393d9c2bb3796a6b458b22c"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73845b61987f44c5d55f9606a38b4078cac7e816dadeeaceb35a49ff741d4c8e"}, {file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9bb9012bc02c44da6af1b931b6082588519685fad8a1f866c341f749394f53f"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:4f05f1b3ac250213db0da6895aeb944ca3cb63cd1a41296f1dddafd8d78a1722"}, {file = "qh3-1.9.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9c7f3781ec022dd958b5eb0e5cb2e2a54e207a3d8837fb7290bc239912648fd7"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3be9e2cfc447807c4195c12d9590052ee45cc17ed92d99f44d05a49d657bf8d4"}, {file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:633a24ab2db76d64881876c9d0f1b226a47d892bd7d78e61ea2c788ab59570af"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:bb83310f908a9d00b781fe95f7acda51025d0cea3a985929e84b0d46333709a2"}, {file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:f2ff36b84a4ab9b68806af1f6985fa3b47e29695effe766a3e70c21efac8157c"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:991ff127a8b0aef9ae07a4155f595158fd005d9cc522d204ec31a5a5f197517c"}, {file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:2100697d736dbdfd1d3f0266c9fcadea6230b66ed59e3e7f8c9a9d1a6eec0c65"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:efbcd87e7c45f51690f6e526ed4956c57e428eb4c5684878987d43ffa14a93b0"}, {file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:9ce179efaf97f927f01fe158619f5624d8d874c7161b7c497f06897cdde210d5"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:86f2256aa78604d6733ff12a225d6780df18a9b171e72d45dd30ee62d5ab0568"}, {file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:476ee4fcdbd3454eb0eb4dee5678294f06c08ca8e8b83160e2d0ae3c4ae34603"},
{file = "qh3-2.0.3-cp314-cp314t-win32.whl", hash = "sha256:91569e8ee979c84a1972916cc9b3283760dda111fc8f67cce45ccdb4f14b67c5"}, {file = "qh3-1.9.4-cp314-cp314t-win32.whl", hash = "sha256:fe9df8136bf358d45a2757287523abaeb208fcbe2c1e34664562eb2d8f64dbdb"},
{file = "qh3-2.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:eb0333f67bee13a3b5f08e91ece87fd0f5e5448e955efb11d7bd2bfacf21bfb5"}, {file = "qh3-1.9.4-cp314-cp314t-win_amd64.whl", hash = "sha256:1fbdf53feb1732faf6c1cb34f2e1cbe4b543054db7b4d19d8752f1162e9370be"},
{file = "qh3-2.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:8c9f6b22d073948b3042ec0b50dab5581865a30d41517ba6aad8dbc1a4b39a6b"}, {file = "qh3-1.9.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e03ee1e2157a31c69415c3dbfbfe64e02469499ca46e968d13d8a7b289bf520a"},
{file = "qh3-2.0.3-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e96296b00eb23ff828d7789070b3d22c7139efbc6bc62fed366c31bad622d174"}, {file = "qh3-1.9.4-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:26f7c8ed1a7ac0e25b654f030a4f472830c80890cc284dc40a31c17ecc21fbf0"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a09e33277ded5f94f9b73b0c8c2f5b3e044f3c41ed5c830ab61e72962e22d09"}, {file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a57b3dfe5dd2da2084adea63dba0bf587a4096ebf9207339187c90139d1f460d"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8bbbc57b1cff5acebf736965c072f35023f4d0e5975b3d8cda7b7e07250af1b"}, {file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7fa1a3debb5199da3c7f45b0ca6efa55174dfd6229370e774099073143ebe9e9"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06b8ee7434cd0ecab6b916abc63a46f4ad8b3ebbc284da405aeb650882826e91"}, {file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6dc9ff40d3a06f27b47d93417666f03ba6fd6b9585d9711ebbddf84fa4b7c50f"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ec715af40af199c92eedbd620bbff8ddc514e19f2bbd660e65953752c364105f"}, {file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:010b132c33719af3df4c1d6f569e52a2a20046c55a31c5532e741019c8c53868"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bcbdc6a574f83f957e6bf430cda8b98a512870164563b7ef1fdd553420f032a"}, {file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:124045b0c04c05afc038a239d7717019b4c4b9d0cb967040b4331f4d588eff7d"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5c9ba43b3f68f9ade9553ff23d89455596b9c5ba8179a103f75c8771d355535"}, {file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a94a1726bf46a451a0295d68fafde28794e83ab6fda7457cbd9309c38151de9"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51480d90b4ac7e81248fd020a726d4089cf8b881a4b6cbdc3c04c329831a3fc6"}, {file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b361e627b6a529ecb231c1d5f3f2abd6d1952b0d7cc02c7718d9657d55dae37"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:235161f4cbd791e18ea865d10fdd52d0ddbcbb807a1849c98360c4e570c06844"}, {file = "qh3-1.9.4-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:43b6cfb2240836e3e54bbe61ad1b8fa5716516ff5359131a2421c55676a24f26"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7abb8ae6c8ae6b49827b26eaffeac0892893450d2022818e1db3ff34d26a835e"}, {file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e6db3def610e9c2d2f3602d3e9c8b2156273225f6eedf74a4749d3bc21af8167"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:57f43869ef306877167277c48c1fa7c446fc73fc9355fa1ccae5094507b63d9e"}, {file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:22d5879dc93d2c7ee14cc5933b12c7780d9c7705dff4553becea152fcb767d99"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:192021606e283a59b47bf1e1cb719087c77869976aea76f26d589a1df8aa0dd5"}, {file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:2c2b2005fe6c605d61cac05ec2b3c019cc546184fa43444f3d1e3d22ed020ab2"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:f33ca1ac278a8d8ff115bcde76fc4555fa1dbfa147e83dad2ebcba6bc663e6d5"}, {file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:e991b0f20b172fea466d0ffd499a85bf2f26decdeeda537275c72b5f7b26ccfe"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:74814e3dd91011235905856701037ead68f7a8be641f469fa5e5cdabf7d03a28"}, {file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c13775ed27a88b297f6a1aa05ffb286879453f99e1e53e0af2b22e7ed8f92ebf"},
{file = "qh3-2.0.3-cp37-abi3-win32.whl", hash = "sha256:20ceb5482ef6f352d9c7351fbde69f94f49d07f068ce9aa4e88ea76fca7eddda"}, {file = "qh3-1.9.4-cp37-abi3-win32.whl", hash = "sha256:952abe1be90c0114993886792a8a5ec2211f8391df41227f5b7a5a02ff71bc26"},
{file = "qh3-2.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:94ba6ddb786742ff93904db09cb4b6fbcaf7de115161c05918024cd52b2c8a61"}, {file = "qh3-1.9.4-cp37-abi3-win_amd64.whl", hash = "sha256:5543cb40a48d401f3fa4ec89398e3a213634f195716a9d06d70417c05edbbb32"},
{file = "qh3-2.0.3-cp37-abi3-win_arm64.whl", hash = "sha256:0b13789c68983a994e8c12e3ff0fbc02f07c0aaaeec5670b57802f5d3d555e81"}, {file = "qh3-1.9.4-cp37-abi3-win_arm64.whl", hash = "sha256:d0bd73b3e73f4f80d2430d410b532222ecf57e9d4fe8600b7683c775cbff9479"},
{file = "qh3-2.0.3.tar.gz", hash = "sha256:546ab2d3193e37e98dfefe5834484e07d0bf531708f21e1a4e227a51510b648f"}, {file = "qh3-1.9.4.tar.gz", hash = "sha256:bd2ea9baf19656c544a48a56a195f2ac257cd973b566f5f2998fa3b7446281a1"},
] ]
[[package]] [[package]]
@@ -553,7 +497,7 @@ files = [
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.52" version = "2.0.51"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "Database Abstraction Library" summary = "Database Abstraction Library"
groups = ["default"] groups = ["default"]
@@ -563,16 +507,22 @@ dependencies = [
"typing-extensions>=4.6.0", "typing-extensions>=4.6.0",
] ]
files = [ files = [
{file = "sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9"}, {file = "sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491"},
{file = "sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e"}, {file = "sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d"},
{file = "sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437"}, {file = "sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54"},
{file = "sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8"}, {file = "sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e"},
{file = "sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd"}, {file = "sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d"},
{file = "sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2"}, {file = "sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8"},
{file = "sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d"}, {file = "sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499"},
{file = "sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582"}, {file = "sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de"},
{file = "sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89"}, {file = "sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7"},
{file = "sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97"}, {file = "sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2"},
{file = "sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5"},
{file = "sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9"},
] ]
[[package]] [[package]]
@@ -599,28 +549,28 @@ files = [
[[package]] [[package]]
name = "urllib3-future" name = "urllib3-future"
version = "2.24.907" version = "2.24.900"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces" summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
groups = ["default"] groups = ["default"]
dependencies = [ dependencies = [
"h11<1.0.0,>=0.11.0", "h11<1.0.0,>=0.11.0",
"jh2<6.0.0,>=5.0.3", "jh2<6.0.0,>=5.0.3",
"qh3<3.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"", "qh3<2.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
] ]
files = [ files = [
{file = "urllib3_future-2.24.907-py3-none-any.whl", hash = "sha256:6f9007bd9c6d3ea13d8fe70e5d4b87a6d15638a849bd2227ab5cbdfbe5d1e842"}, {file = "urllib3_future-2.24.900-py3-none-any.whl", hash = "sha256:a4fe06b59b0c3ce2fffa821c3de72eaf59b1e61619becc255dee18919ee25260"},
{file = "urllib3_future-2.24.907.tar.gz", hash = "sha256:6565b3cc160a950821953079a6ccd4daa8fd60071a13c9a251576648929f3aa7"}, {file = "urllib3_future-2.24.900.tar.gz", hash = "sha256:05c2e9d09293ab29a154fed8f5b60644c7cdaffe4ae2587a07b1c7f00f23a7fb"},
] ]
[[package]] [[package]]
name = "wassima" name = "wassima"
version = "2.1.4" version = "2.1.3"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "Access your OS root certificates with utmost ease" summary = "Access your OS root certificates with utmost ease"
groups = ["default"] groups = ["default"]
marker = "sys_platform != \"emscripten\"" marker = "sys_platform != \"emscripten\""
files = [ files = [
{file = "wassima-2.1.4-py3-none-any.whl", hash = "sha256:26022272d4618aada0e26df2e79dbc0e034be8be9d6d5396512690ffc6a94e7b"}, {file = "wassima-2.1.3-py3-none-any.whl", hash = "sha256:f8251c23720dd092117a554b3012756caee3feed6d0122c2458a2aca5d7d92c5"},
{file = "wassima-2.1.4.tar.gz", hash = "sha256:21bb77253bb8032c05393172c4d2df395f423b7e704538f3b2407f50c032034d"}, {file = "wassima-2.1.3.tar.gz", hash = "sha256:fcd6c38be0f909c393da35cb2a993101fcdcff673b8fa8d5da228f73b630d0d0"},
] ]
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "plyngent" name = "plyngent"
version = "0.6.0" version = "0.5.0"
description = "LLM chat and agent toolkit" description = "LLM chat and agent toolkit"
authors = [ authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" }, { name = "HivertMoZara", email = "worldmozara@163.com" },
+4 -31
View File
@@ -124,14 +124,9 @@ def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None:
return text or None return text or None
def _setup_hooks(*, interactive: bool, auto_continue: bool) -> None: def _setup_hooks(*, interactive: bool) -> None:
"""Install limit hooks (workspace policy is set on ReplState). """Install interactive limit hooks (workspace policy is set on ReplState)."""
if interactive:
``auto_continue`` keeps the PTY/limit hook installed for one-shot runs so
``--auto-continue`` / ``[agent] auto_continue_limits`` can raise limits
without a prompt; otherwise non-interactive runs keep today's deny.
"""
if interactive or auto_continue:
install_cli_limit_hooks() install_cli_limit_hooks()
else: else:
from plyngent.tools.process.pty_session import PtyManager from plyngent.tools.process.pty_session import PtyManager
@@ -226,7 +221,6 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
prompt_text: str | None, prompt_text: str | None,
stream: bool, stream: bool,
yes: bool, yes: bool,
auto_continue: bool,
quiet: bool, quiet: bool,
) -> int: ) -> int:
oneshot = prompt_text is not None oneshot = prompt_text is not None
@@ -242,8 +236,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
if store.recoverable_providers: if store.recoverable_providers:
_warn_recoverable_providers(store.recoverable_providers) _warn_recoverable_providers(store.recoverable_providers)
auto_effective = auto_continue or store.agent_config.auto_continue_limits _setup_hooks(interactive=interactive)
_setup_hooks(interactive=interactive, auto_continue=auto_effective)
# --yes forces sticky YOLO; else derive from config.confirm_destructive. # --yes forces sticky YOLO; else derive from config.confirm_destructive.
from plyngent.cli.state import YoloMode from plyngent.cli.state import YoloMode
@@ -322,7 +315,6 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
max_rounds=max_rounds, max_rounds=max_rounds,
stream_enabled=stream, stream_enabled=stream,
interactive_limits=interactive, interactive_limits=interactive,
auto_continue_limits=auto_effective,
yolo=yolo, yolo=yolo,
mcp_manager=mcp_manager, mcp_manager=mcp_manager,
) )
@@ -341,10 +333,6 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
prompt_policy_fetch_confirm, prompt_policy_fetch_confirm,
instance=state.instance_state, instance=state.instance_state,
) )
from plyngent.tools.access import set_directory_access_confirm_hook
# Installed for one-shot too: the hook itself honours --yes / non-TTY.
set_directory_access_confirm_hook(state.directory_access_confirm_hook, instance=state.instance_state)
# Seed cache if we already fetched; else warm in background for Tab. # Seed cache if we already fetched; else warm in background for Tab.
if remote_ids is not None: if remote_ids is not None:
state.seed_remote_models(remote_ids) state.seed_remote_models(remote_ids)
@@ -385,13 +373,6 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
set_fetch_policy_confirm_hook(None, instance=state_obj.instance_state) set_fetch_policy_confirm_hook(None, instance=state_obj.instance_state)
clear_private_grants(instance=state_obj.instance_state) clear_private_grants(instance=state_obj.instance_state)
clear_ssrf_assume_public_cidrs() clear_ssrf_assume_public_cidrs()
from plyngent.tools.access import clear_process_access, set_directory_access_confirm_hook
set_directory_access_confirm_hook(None, instance=state_obj.instance_state)
clear_process_access(instance=state_obj.instance_state)
from plyngent.cli.limits import reset_auto_continue
reset_auto_continue()
if state_obj.mcp_manager is not None: if state_obj.mcp_manager is not None:
await state_obj.mcp_manager.aclose() await state_obj.mcp_manager.aclose()
await state_obj.instance_state.shutdown() await state_obj.instance_state.shutdown()
@@ -488,12 +469,6 @@ def main(ctx: click.Context, log_level: str) -> None:
default=False, default=False,
help="Enable YOLO: skip destructive-tool confirms (sticky for this process).", help="Enable YOLO: skip destructive-tool confirms (sticky for this process).",
) )
@click.option(
"--auto-continue",
is_flag=True,
default=False,
help="Auto-raise tool/PTY limits without prompting (like answering yyy).",
)
@click.option( @click.option(
"--quiet", "--quiet",
is_flag=True, is_flag=True,
@@ -512,7 +487,6 @@ def chat_cmd(
prompt: str | None, prompt: str | None,
stream: bool, # noqa: FBT001 stream: bool, # noqa: FBT001
yes: bool, # noqa: FBT001 yes: bool, # noqa: FBT001
auto_continue: bool, # noqa: FBT001
quiet: bool, # noqa: FBT001 quiet: bool, # noqa: FBT001
) -> None: ) -> None:
"""Interactive chat REPL, or one-shot with ``-p`` / stdin. """Interactive chat REPL, or one-shot with ``-p`` / stdin.
@@ -553,7 +527,6 @@ def chat_cmd(
prompt_text=prompt_text, prompt_text=prompt_text,
stream=stream, stream=stream,
yes=yes, yes=yes,
auto_continue=auto_continue,
quiet=quiet, quiet=quiet,
) )
) )
+8 -124
View File
@@ -4,7 +4,6 @@ import contextlib
import selectors import selectors
import shutil import shutil
import sys import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
from plyngent.cli.interrupt import pause_task_cancel_for_prompt from plyngent.cli.interrupt import pause_task_cancel_for_prompt
@@ -19,56 +18,16 @@ from plyngent.prompting import (
confirm, confirm,
get_prompt_backend, get_prompt_backend,
) )
from plyngent.tools.access import AccessDecision
from plyngent.tools.process.pty_session import PtyManager from plyngent.tools.process.pty_session import PtyManager
from plyngent.tools.workspace import AccessMode, parse_access_mode
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from pathlib import Path
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"] type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
_BOX_MIN_WIDTH = 40 _BOX_MIN_WIDTH = 40
_BOX_MAX_WIDTH = 100 _BOX_MAX_WIDTH = 100
_BOX_PAD = 2 # spaces inside left/right borders _BOX_PAD = 2 # spaces inside left/right borders
# Limit prompts accept ``yyy`` to stop asking for the rest of the turn.
_CONTINUE_PROMPT = "Raise limit and continue? [Y/n/yyy] (yyy = stop asking this turn): "
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
_CONTINUE_AUTO_TOKEN = "yyy"
@dataclass
class _AutoContinue:
"""Auto-continue layers: process default (flag/config) + ``yyy`` turn skip."""
default: bool = False
turn: bool = False
_auto_continue = _AutoContinue()
def auto_continue_enabled() -> bool:
"""Whether limit prompts are auto-approved (process default or ``yyy``)."""
return _auto_continue.default or _auto_continue.turn
def set_auto_continue_default(*, enabled: bool) -> None:
"""Set the process-wide default (``--auto-continue`` / ``[agent]`` config)."""
_auto_continue.default = bool(enabled)
def reset_auto_continue_turn() -> None:
"""Clear the ``yyy`` skip at a turn boundary."""
_auto_continue.turn = False
def reset_auto_continue() -> None:
"""Clear both layers (chat exit / tests)."""
_auto_continue.default = False
_auto_continue.turn = False
def _terminal_width() -> int: def _terminal_width() -> int:
try: try:
@@ -145,33 +104,10 @@ def _echo_continue_limit(reason: str) -> None:
backend.echo() backend.echo()
def _echo_auto_continue(reason: str) -> None:
"""Echo the limit reason plus the ``yyy`` notice (no prompt)."""
backend = get_prompt_backend()
backend.secho(f"[limit] {reason}", fg="yellow")
backend.secho("[limit] auto-continuing (yyy) — no more limit prompts this turn", fg="bright_black")
backend.echo()
def _decide_continue_limit(reason: str) -> bool:
"""Shared limit decision for the sync/async prompts (may read a line)."""
if auto_continue_enabled():
_echo_auto_continue(reason)
return True
backend = get_prompt_backend()
if not backend.is_interactive():
return False
_echo_continue_limit(reason)
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower()
if token == _CONTINUE_AUTO_TOKEN:
_auto_continue.turn = True
return True
return token in _CONTINUE_YES_TOKENS
def _prompt_continue_limit_sync(reason: str) -> bool: def _prompt_continue_limit_sync(reason: str) -> bool:
try: try:
return _decide_continue_limit(reason) _echo_continue_limit(reason)
return confirm("Raise limit and continue?", default=True)
except NonInteractiveError: except NonInteractiveError:
return False return False
@@ -183,11 +119,15 @@ def prompt_continue_limit(reason: str) -> bool:
async def prompt_continue_limit_async(reason: str) -> bool: async def prompt_continue_limit_async(reason: str) -> bool:
"""Async variant: prompt off the event loop so the turn is not cancelled.""" """Async variant: confirm off the event loop so the turn is not cancelled."""
try: try:
from plyngent.prompting import run_prompt_async from plyngent.prompting import run_prompt_async
return await run_prompt_async(lambda: _decide_continue_limit(reason)) def _run() -> bool:
_echo_continue_limit(reason)
return confirm("Raise limit and continue?", default=True)
return await run_prompt_async(_run)
except NonInteractiveError: except NonInteractiveError:
return False return False
@@ -347,62 +287,6 @@ def prompt_policy_fetch_confirm(
return False return False
def prompt_directory_access_confirm(
path: Path,
mode: AccessMode,
reason: str,
timeout_seconds: float,
) -> AccessDecision | str | None:
"""Ask whether to grant access to *path* at *mode* (timed; default deny).
``y`` approves the requested level; ``read`` / ``write`` / ``exec`` approve
that level instead. The host's hook decides YOLO/``--yes`` before calling
this. Timeout, cancel, or non-interactive → ``None`` (deny).
"""
lines = [
f"request: {mode.name.lower()} access to {path}",
f"kind: {'directory' if path.is_dir() else 'file'}",
]
if reason:
lines.append(f"why: {reason}")
lines.append(f"(timeout {timeout_seconds:g}s defaults to DENY)")
try:
with pause_task_cancel_for_prompt():
backend = get_prompt_backend()
if not backend.is_interactive():
return None
backend.echo()
backend.secho(
format_tool_confirm_box("request_directory_access", "\n".join(lines)),
fg="yellow",
)
backend.echo()
prompt = (
f"[access] allow {path} ({mode.name.lower()})? [y/N|read|write|exec] (timeout {timeout_seconds:g}s): "
)
with contextlib.suppress(OSError):
_ = sys.stderr.write(prompt)
_ = sys.stderr.flush()
raw = _read_yes_no_line_with_timeout(timeout_seconds)
if raw is None:
with contextlib.suppress(OSError, NonInteractiveError):
backend.secho(
f"[access] timed out after {timeout_seconds:g}s — denied",
fg="red",
err=True,
)
return None
token = raw.strip().lower()
if token in {"y", "yes"}:
return AccessDecision(mode)
level = parse_access_mode(token)
if level is not None:
return AccessDecision(level)
return None
except NonInteractiveError, KeyboardInterrupt, EOFError:
return None
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | str: async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | str:
"""Async confirm: True allow, False deny, str = deny with user comment.""" """Async confirm: True allow, False deny, str = deny with user comment."""
del args del args
+1 -4
View File
@@ -36,17 +36,14 @@ def _echo_interrupted() -> None:
async def run_repl(state: ReplState) -> None: async def run_repl(state: ReplState) -> None:
"""Interactive chat loop with readline editing, history, and Tab completion.""" """Interactive chat loop with readline editing, history, and Tab completion."""
setup_readline(state) setup_readline(state)
from plyngent.cli.limits import auto_continue_enabled
yolo = state.effective_yolo() yolo = state.effective_yolo()
yolo_part = f" yolo={yolo}" if yolo != "off" else "" yolo_part = f" yolo={yolo}" if yolo != "off" else ""
auto_part = " limit_auto=on" if auto_continue_enabled() else ""
click.echo( click.echo(
f"plyngent chat provider={state.provider_name} model={state.model} " f"plyngent chat provider={state.provider_name} model={state.model} "
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} " f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
f"rounds={state.max_rounds} messages={len(state.agent.messages)} " f"rounds={state.max_rounds} messages={len(state.agent.messages)} "
f"stream={'on' if state.agent.stream else 'off'} " f"stream={'on' if state.agent.stream else 'off'} "
f"verbose={'on' if state.verbose else 'off'}{yolo_part}{auto_part}" f"verbose={'on' if state.verbose else 'off'}{yolo_part}"
) )
click.echo('Type /help for commands. Multiline: """ … """. Empty line is ignored.') click.echo('Type /help for commands. Multiline: """ … """. Empty line is ignored.')
-17
View File
@@ -10,7 +10,6 @@ import click
from plyngent.cli.display import render_events from plyngent.cli.display import render_events
from plyngent.cli.interrupt import allow_task_cancel, set_sigint_reinstall from plyngent.cli.interrupt import allow_task_cancel, set_sigint_reinstall
from plyngent.cli.limits import reset_auto_continue_turn
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Coroutine from collections.abc import AsyncIterator, Callable, Coroutine
@@ -185,23 +184,7 @@ async def run_turn_with_retries(
further attempts use ``agent.retry`` so the user message is not duplicated. further attempts use ``agent.retry`` so the user message is not duplicated.
Ctrl+C cancels the in-flight task; user message stays in DB for ``/retry``. Ctrl+C cancels the in-flight task; user message stays in DB for ``/retry``.
The ``yyy`` limit auto-continue is turn-scoped: cleared before and after the
turn, so a retry of the same turn keeps it but the next user turn prompts.
""" """
reset_auto_continue_turn()
try:
return await _run_turn_with_retries(agent, starter=starter, delays=delays)
finally:
reset_auto_continue_turn()
async def _run_turn_with_retries(
agent: ChatAgent,
*,
starter: Callable[[], AsyncIterator[AgentEvent]],
delays: tuple[float, ...],
) -> bool:
max_retries = len(delays) max_retries = len(delays)
attempt = 0 attempt = 0
current: Callable[[], AsyncIterator[AgentEvent]] = starter current: Callable[[], AsyncIterator[AgentEvent]] = starter
-72
View File
@@ -521,7 +521,6 @@ def config_cmd(state: ReplState) -> None:
def status_cmd(state: ReplState) -> None: def status_cmd(state: ReplState) -> None:
"""Show session/provider/tools/rounds status.""" """Show session/provider/tools/rounds status."""
from plyngent.agent.budget import estimate_messages_chars from plyngent.agent.budget import estimate_messages_chars
from plyngent.tools.access import access_grant_rows
pending = state.agent.pending_retry_text pending = state.agent.pending_retry_text
pending_disp = "yes" if pending else "no" pending_disp = "yes" if pending else "no"
@@ -556,7 +555,6 @@ def status_cmd(state: ReplState) -> None:
f"usage_last_turn={last_u.format_line(billed=True)} " f"usage_last_turn={last_u.format_line(billed=True)} "
f"rounds={last_rounds}\n" f"rounds={last_rounds}\n"
f"usage_session={session_u.format_line(billed=True)}\n" f"usage_session={session_u.format_line(billed=True)}\n"
f"grants={len(access_grant_rows(instance=state.instance_state, session=state.session_state))} "
f"workspace={state.workspace}" f"workspace={state.workspace}"
) )
@@ -1018,17 +1016,11 @@ def btw_cmd(
else: else:
await run_cancellable(side) await run_cancellable(side)
from plyngent.cli.limits import reset_auto_continue_turn
# Aside is its own exchange: a ``yyy`` from the main turn must not leak in.
reset_auto_continue_turn()
try: try:
_await(_run()) _await(_run())
except Exception as exc: # noqa: BLE001 — surface side-turn failures except Exception as exc: # noqa: BLE001 — surface side-turn failures
click.secho(f"error: btw failed: {exc}", fg="red") click.secho(f"error: btw failed: {exc}", fg="red")
return return
finally:
reset_auto_continue_turn()
click.secho("(btw done — main session unchanged)", fg="bright_black") click.secho("(btw done — main session unchanged)", fg="bright_black")
@@ -1213,70 +1205,6 @@ def mcp_slash_cmd(state: ReplState, action: str | None) -> None:
_print_mcp_status(state) _print_mcp_status(state)
@slash.command("grants")
@click.argument("action", required=False, type=click.Choice(["list", "revoke"]))
@click.argument("target", required=False)
@click.pass_obj
def grants_cmd(state: ReplState, action: str | None, target: str | None) -> None:
"""List or revoke directory-access grants.
``/grants`` or ``/grants list`` — session, process, then config grants.
``/grants revoke <index|all>`` — drop one grant or all session/process
grants. Config grants come from ``[agent].allow_paths``; edit the TOML.
"""
from plyngent.tools.access import (
access_grant_rows,
clear_process_access,
clear_session_access,
revoke_access,
)
act = (action or "list").lower()
if act == "list":
rows = access_grant_rows(instance=state.instance_state, session=state.session_state)
if not rows:
click.echo("no directory-access grants")
return
click.echo(f"directory-access grants ({len(rows)}):")
for index, (path, mode, source) in enumerate(rows):
click.echo(f" {index}\t{path}\t{mode.name.lower()}\t{source}")
click.echo("revoke with /grants revoke <index> or /grants revoke all")
return
if target is None or not target.strip():
msg = "/grants revoke requires an index or 'all'"
raise click.UsageError(msg)
token = target.strip().lower()
if token == "all":
clear_session_access(session=state.session_state)
clear_process_access(instance=state.instance_state)
_await(state.persist_access_grants())
click.echo("revoked all session/process access grants (config grants unchanged)")
return
if not token.isdigit():
msg = "/grants revoke expects an index or 'all'"
raise click.UsageError(msg)
rows = access_grant_rows(instance=state.instance_state, session=state.session_state)
index = int(token)
if index < 0 or index >= len(rows):
msg = f"no grant at index {index}"
raise click.UsageError(msg)
path, _mode, source = rows[index]
if source == "config":
click.echo(f"config grant {path} is not revocable here; edit [agent].allow_paths")
return
if revoke_access(
path,
source=source,
instance=state.instance_state,
session=state.session_state,
):
_await(state.persist_access_grants())
click.echo(f"revoked {path} ({source})")
else:
click.echo(f"grant not found: {path}")
@slash.command("yolo") @slash.command("yolo")
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]") @click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
@click.pass_obj @click.pass_obj
+4 -104
View File
@@ -26,31 +26,17 @@ from plyngent.tools import InstanceState, SessionState
from plyngent.tools.view import MemoryViewStore, session_data_view from plyngent.tools.view import MemoryViewStore, session_data_view
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping, Sequence from collections.abc import Sequence
from plyngent.config.models import Provider from plyngent.config.models import Provider
from plyngent.config.store import ConfigStore from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore from plyngent.memory import MemoryStore
from plyngent.memory.database.schema import Session as SessionRow from plyngent.memory.database.schema import Session as SessionRow
from plyngent.runtime.mcp_client import McpManager from plyngent.runtime.mcp_client import McpManager
from plyngent.tools.access import AccessDecision
from plyngent.tools.workspace import AccessMode
type YoloMode = Literal["off", "on", "once"] type YoloMode = Literal["off", "on", "once"]
def _parsed_access_grants(raw: Mapping[str, str]) -> dict[Path, AccessMode]:
"""Parse stored ``{path: mode}`` grants into resolved keys (skip bad modes)."""
from plyngent.tools.workspace import parse_access_mode
grants: dict[Path, AccessMode] = {}
for path_str, mode_str in raw.items():
mode = parse_access_mode(mode_str)
if mode is not None:
grants[Path(path_str).expanduser().resolve()] = mode
return grants
@dataclass @dataclass
class ReplState: class ReplState:
"""Mutable REPL session state.""" """Mutable REPL session state."""
@@ -69,8 +55,6 @@ class ReplState:
markdown_enabled: bool = True markdown_enabled: bool = True
# One-shot / scripts: never prompt to raise tool-loop limits. # One-shot / scripts: never prompt to raise tool-loop limits.
interactive_limits: bool = True interactive_limits: bool = True
# Auto-raise tool/PTY limits without prompting (--auto-continue / config).
auto_continue_limits: bool = False
# Soft destructive-tool confirms: None → derive from config.confirm_destructive. # Soft destructive-tool confirms: None → derive from config.confirm_destructive.
# off = confirm; on = skip (sticky); once = skip next user turn then off. # off = confirm; on = skip (sticky); once = skip next user turn then off.
yolo: YoloMode | None = None yolo: YoloMode | None = None
@@ -98,10 +82,6 @@ class ReplState:
self.workspace = Path(self.workspace).expanduser().resolve() self.workspace = Path(self.workspace).expanduser().resolve()
self.instance_state.workspace_root = self.workspace self.instance_state.workspace_root = self.workspace
self.instance_state.workspace.root = self.workspace self.instance_state.workspace.root = self.workspace
self._install_config_access()
from plyngent.cli.limits import set_auto_continue_default
set_auto_continue_default(enabled=self.auto_continue_limits)
self.session_state = self._session_data_for_todo() self.session_state = self._session_data_for_todo()
self.agent = self._make_agent() self.agent = self._make_agent()
self.sync_display_flags() self.sync_display_flags()
@@ -113,12 +93,6 @@ class ReplState:
set_verbose_tool_results(self.verbose) set_verbose_tool_results(self.verbose)
set_markdown_enabled(self.markdown_enabled) set_markdown_enabled(self.markdown_enabled)
def _reset_limit_auto_continue(self) -> None:
"""Drop a ``yyy`` skip on session change (it is turn-scoped)."""
from plyngent.cli.limits import reset_auto_continue_turn
reset_auto_continue_turn()
def _workspace_key(self) -> str: def _workspace_key(self) -> str:
key = normalize_workspace(self.workspace) key = normalize_workspace(self.workspace)
if key is None: if key is None:
@@ -174,17 +148,11 @@ class ReplState:
self._todo_persist_tasks.add(task) self._todo_persist_tasks.add(task)
task.add_done_callback(self._todo_persist_tasks.discard) task.add_done_callback(self._todo_persist_tasks.discard)
def _session_data_for_todo( def _session_data_for_todo(self, *, grants: dict[str, bool] | None = None) -> SessionState:
self,
*,
grants: dict[str, bool] | None = None,
access_grants: dict[Path, AccessMode] | None = None,
) -> SessionState:
"""Build a SessionState with live todo + MemoryViewStore seed (durable tree). """Build a SessionState with live todo + MemoryViewStore seed (durable tree).
*grants* defaults to empty (new session / startup). Pass the previous map *grants* defaults to empty (new session / startup). Pass the previous map
only when intentionally preserving soft-confirm trust across rebinds. only when intentionally preserving soft-confirm trust across rebinds.
*access_grants* seeds session directory grants (empty clears them).
""" """
grant_map = dict(grants or {}) grant_map = dict(grants or {})
store = MemoryViewStore({"todo": self.todo_stack.to_raw(), "grants": dict(grant_map)}) store = MemoryViewStore({"todo": self.todo_stack.to_raw(), "grants": dict(grant_map)})
@@ -193,9 +161,7 @@ class ReplState:
data=session_data_view(store=store), data=session_data_view(store=store),
todo=self.todo_stack, todo=self.todo_stack,
on_todo_change=self._todo_on_change, on_todo_change=self._todo_on_change,
on_access_change=self._on_access_change,
grants=grant_map, grants=grant_map,
access_grants=dict(access_grants or {}),
) )
def _bind_todo_tools(self) -> None: def _bind_todo_tools(self) -> None:
@@ -237,67 +203,6 @@ class ReplState:
if hasattr(self, "agent"): if hasattr(self, "agent"):
self.agent.todo_stack = self.todo_stack self.agent.todo_stack = self.todo_stack
def _on_access_change(self) -> None:
"""Schedule memory persist for live directory-access grants."""
import asyncio
if self.session_id is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = loop.create_task(self.persist_access_grants())
self._todo_persist_tasks.add(task)
task.add_done_callback(self._todo_persist_tasks.discard)
async def persist_access_grants(self) -> None:
"""Write live session directory grants to the active session row."""
if self.session_id is None:
return
payload = {str(path): mode.name.lower() for path, mode in self.session_state.access_grants.items()}
_ = await self.memory.update_session_access_grants(self.session_id, payload)
async def load_access_grants(self) -> None:
"""Load durable session directory grants into the live map (replace)."""
self.session_state.access_grants.clear()
if self.session_id is None:
return
raw = await self.memory.get_session_access_grants(self.session_id)
self.session_state.access_grants.update(_parsed_access_grants(raw))
def directory_access_confirm_hook(
self,
path: Path,
mode: AccessMode,
reason: str,
timeout_seconds: float,
) -> AccessDecision | str | None:
"""Directory-access confirm: YOLO/``--yes`` auto-approves, else ask."""
from plyngent.cli.limits import prompt_directory_access_confirm
from plyngent.tools.access import AccessDecision as Decision
if self.effective_yolo() != "off":
return Decision(mode, persist=False)
return prompt_directory_access_confirm(path, mode, reason, timeout_seconds)
def _install_config_access(self) -> None:
"""Install ``[agent].allow_paths`` static grants on the policy bag."""
import click
from plyngent.tools.access import set_config_access
_applied, skipped = set_config_access(
self.config.agent_config.allow_paths,
instance=self.instance_state,
)
if skipped:
click.secho(
f"warning: ignored allow_paths entries (missing path or bad mode): {', '.join(skipped)}",
fg="yellow",
err=True,
)
def _tool_registry(self) -> ToolRegistry | None: def _tool_registry(self) -> ToolRegistry | None:
if not self.tools_enabled: if not self.tools_enabled:
return None return None
@@ -345,7 +250,7 @@ class ReplState:
return "\n\n".join(parts) return "\n\n".join(parts)
def _make_agent(self) -> ChatAgent: def _make_agent(self) -> ChatAgent:
from plyngent.cli.limits import auto_continue_enabled, prompt_continue_limit_async from plyngent.cli.limits import prompt_continue_limit_async
from plyngent.config import compose_agent_system_content from plyngent.config import compose_agent_system_content
agent_cfg = self.config.agent_config agent_cfg = self.config.agent_config
@@ -355,7 +260,7 @@ class ReplState:
agent_cfg.tool_directives, agent_cfg.tool_directives,
mcp_text, mcp_text,
) )
on_limit = prompt_continue_limit_async if (self.interactive_limits or auto_continue_enabled()) else None on_limit = prompt_continue_limit_async if self.interactive_limits else None
peak = 0 peak = 0
band = 0 band = 0
last_req = None last_req = None
@@ -669,7 +574,6 @@ class ReplState:
return False return False
async def new_session(self, name: str = "chat") -> None: async def new_session(self, name: str = "chat") -> None:
self._reset_limit_auto_continue()
session = await self.memory.create_session( session = await self.memory.create_session(
name=name, name=name,
workspace=self.workspace, workspace=self.workspace,
@@ -708,7 +612,6 @@ class ReplState:
"""Load a session; on workspace mismatch, prompt keep / rebind / abort.""" """Load a session; on workspace mismatch, prompt keep / rebind / abort."""
from plyngent.cli.limits import prompt_workspace_mismatch from plyngent.cli.limits import prompt_workspace_mismatch
self._reset_limit_auto_continue()
row = await self.memory.get_session(session_id) row = await self.memory.get_session(session_id)
if row is None: if row is None:
msg = f"session not found: {session_id}" msg = f"session not found: {session_id}"
@@ -742,11 +645,9 @@ class ReplState:
self.agent = self._make_agent() self.agent = self._make_agent()
await self.agent.load_history() await self.agent.load_history()
await self.load_todo_stack() await self.load_todo_stack()
await self.load_access_grants()
async def resume_latest_or_new(self, name: str = "chat") -> str: async def resume_latest_or_new(self, name: str = "chat") -> str:
"""Resume most recently updated session for this workspace, or create one.""" """Resume most recently updated session for this workspace, or create one."""
self._reset_limit_auto_continue()
latest = await self.memory.get_latest_session(workspace=self.workspace) latest = await self.memory.get_latest_session(workspace=self.workspace)
if latest is None: if latest is None:
await self.new_session(name=name) await self.new_session(name=name)
@@ -759,7 +660,6 @@ class ReplState:
self.agent = self._make_agent() self.agent = self._make_agent()
await self.agent.load_history() await self.agent.load_history()
await self.load_todo_stack() await self.load_todo_stack()
await self.load_access_grants()
_ = await self.memory.touch_session(latest.sid) _ = await self.memory.touch_session(latest.sid)
return "resume" return "resume"
-11
View File
@@ -10,9 +10,6 @@ from msgspec import Struct, field
# ``https://api.deepseek.com/anthropic``) # ``https://api.deepseek.com/anthropic``)
type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"] type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"]
# Static directory pre-allow mode for ``[agent].allow_paths`` (path → mode).
type AllowPathMode = Literal["read", "write", "exec"]
# Built-in persona when ``[agent].system_prompt`` is omitted. # Built-in persona when ``[agent].system_prompt`` is omitted.
# Set ``system_prompt = ""`` to omit the persona block only. # Set ``system_prompt = ""`` to omit the persona block only.
# Override with a multi-line TOML literal (prefer ''' so nested " is fine). # Override with a multi-line TOML literal (prefer ''' so nested " is fine).
@@ -26,8 +23,6 @@ DEFAULT_TOOL_DIRECTIVES = """\
### Workspace ### Workspace
- Explore with `tree`/`listdir`/`glob_paths`/`grep_files`/`read_file` when unsure. - Explore with `tree`/`listdir`/`glob_paths`/`grep_files`/`read_file` when unsure.
- Stay under the workspace (or a path from `new_temporary_workspace`). Respect path denylists. - Stay under the workspace (or a path from `new_temporary_workspace`). Respect path denylists.
- Need a path outside the workspace? Call `request_directory_access` \
(human approves; denylists apply) instead of retrying an escaped path.
### Files ### Files
- Prefer file tools over shell. `edit_replace` fails usually means a bad match — \ - Prefer file tools over shell. `edit_replace` fails usually means a bad match — \
@@ -114,12 +109,6 @@ class AgentConfig(Struct, omit_defaults=True):
parallel_tools: bool = True parallel_tools: bool = True
confirm_destructive: bool = True confirm_destructive: bool = True
path_denylist: list[str] = field(default_factory=list) path_denylist: list[str] = field(default_factory=list)
# Static directory pre-allow (resolved path → mode); never prompts.
# Denylist still applies, and the path must exist when chat starts.
allow_paths: dict[str, AllowPathMode] = field(default_factory=dict)
# Auto-raise tool/PTY limits without prompting (same as answering ``yyy``);
# process-wide, so every turn skips the limit confirm.
auto_continue_limits: bool = False
max_context_tokens: int = 200_000 max_context_tokens: int = 200_000
# How to inject todo stack nags into model context (see agent/todo_nag.py). # How to inject todo stack nags into model context (see agent/todo_nag.py).
-2
View File
@@ -34,8 +34,6 @@ class Session(PlyngentBase):
model: Mapped[str | None] = mapped_column(String(256), nullable=True) model: Mapped[str | None] = mapped_column(String(256), nullable=True)
# Todo/task stack JSON for multi-step sub-tasks (optional). # Todo/task stack JSON for multi-step sub-tasks (optional).
todo_stack: Mapped[dict[str, object] | None] = mapped_column(JSON(), nullable=True) todo_stack: Mapped[dict[str, object] | None] = mapped_column(JSON(), nullable=True)
# Directory-access grants approved for this session (resolved path → mode).
access_grants: Mapped[dict[str, str] | None] = mapped_column(JSON(), nullable=True)
# Last model request context size (API prompt_tokens preferred). # Last model request context size (API prompt_tokens preferred).
last_prompt_tokens: Mapped[int | None] = mapped_column(nullable=True) last_prompt_tokens: Mapped[int | None] = mapped_column(nullable=True)
peak_prompt_tokens: Mapped[int | None] = mapped_column(nullable=True) peak_prompt_tokens: Mapped[int | None] = mapped_column(nullable=True)
+2 -39
View File
@@ -241,31 +241,6 @@ class MemoryStore:
await session.refresh(row) await session.refresh(row)
return row return row
async def get_session_access_grants(self, sid: int) -> dict[str, str]:
"""Return stored directory-access grants (resolved path → mode)."""
async with self._session_factory() as session:
row = await session.get(Session, sid)
if row is None:
msg = f"session not found: {sid}"
raise ValueError(msg)
raw = row.access_grants
if raw is None:
return {}
return {str(k): str(v) for k, v in cast("dict[object, object]", raw).items()}
async def update_session_access_grants(self, sid: int, grants: Mapping[str, str]) -> Session:
"""Persist directory-access grants for a session (empty clears)."""
async with self._session_factory() as session:
row = await session.get(Session, sid)
if row is None:
msg = f"session not found: {sid}"
raise ValueError(msg)
row.access_grants = dict(grants)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return row
async def update_session_context_usage( async def update_session_context_usage(
self, self,
sid: int, sid: int,
@@ -425,30 +400,18 @@ def _migrate_session_v3(sync_conn: object, columns: set[str]) -> None:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN reminder_last_band INTEGER")) _ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN reminder_last_band INTEGER"))
def _migrate_session_v4(sync_conn: object, columns: set[str]) -> None:
"""Post-v0.5.0: add ``access_grants`` JSON for session directory-access grants."""
from sqlalchemy.engine import Connection
if not isinstance(sync_conn, Connection):
return
if "access_grants" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN access_grants JSON"))
# Latest schema version this build knows. Bump (and append a step to # Latest schema version this build knows. Bump (and append a step to
# _SCHEMA_MIGRATIONS) when a new release adds or changes columns. # _SCHEMA_MIGRATIONS) when a new release adds or changes columns.
_SCHEMA_VERSION = 4 _SCHEMA_VERSION = 3
# Ordered (version, step) pairs. Each step receives the current ``session`` # Ordered (version, step) pairs. Each step receives the current ``session``
# column set (read once) and adds only its own missing columns, so it is # column set (read once) and adds only its own missing columns, so it is
# idempotent for DBs that were upgraded incrementally without a version stamp. # idempotent for DBs that were upgraded incrementally without a version stamp.
# Version 1 shipped in v0.1.0, version 2 in v0.1.2, version 3 post-v0.2.0, # Version 1 shipped in v0.1.0, version 2 in v0.1.2, version 3 post-v0.2.0.
# version 4 post-v0.5.0.
_SCHEMA_MIGRATIONS: tuple[tuple[int, MigrationStep], ...] = ( _SCHEMA_MIGRATIONS: tuple[tuple[int, MigrationStep], ...] = (
(1, _migrate_session_v1), (1, _migrate_session_v1),
(2, _migrate_session_v2), (2, _migrate_session_v2),
(3, _migrate_session_v3), (3, _migrate_session_v3),
(4, _migrate_session_v4),
) )
-20
View File
@@ -1,17 +1,3 @@
from .access import ACCESS_TOOLS as ACCESS_TOOLS
from .access import MAX_ACCESS_GRANTS as MAX_ACCESS_GRANTS
from .access import AccessDecision as AccessDecision
from .access import access_grant_rows as access_grant_rows
from .access import active_grant_count as active_grant_count
from .access import clear_process_access as clear_process_access
from .access import clear_session_access as clear_session_access
from .access import get_directory_access_confirm_hook as get_directory_access_confirm_hook
from .access import grant_process_access as grant_process_access
from .access import grant_session_access as grant_session_access
from .access import request_directory_access as request_directory_access
from .access import revoke_access as revoke_access
from .access import set_config_access as set_config_access
from .access import set_directory_access_confirm_hook as set_directory_access_confirm_hook
from .catalog import ToolCatalog as ToolCatalog from .catalog import ToolCatalog as ToolCatalog
from .catalog import ToolSource as ToolSource from .catalog import ToolSource as ToolSource
from .catalog import catalog_scope as catalog_scope from .catalog import catalog_scope as catalog_scope
@@ -80,7 +66,6 @@ from .workspace import (
DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST, DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST,
) )
from .workspace import DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS as DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS from .workspace import DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS as DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
from .workspace import AccessMode as AccessMode
from .workspace import WorkspaceError as WorkspaceError from .workspace import WorkspaceError as WorkspaceError
from .workspace import WorkspacePolicy as WorkspacePolicy from .workspace import WorkspacePolicy as WorkspacePolicy
from .workspace import active_workspace_policy as active_workspace_policy from .workspace import active_workspace_policy as active_workspace_policy
@@ -89,16 +74,12 @@ from .workspace import check_command_allowed as check_command_allowed
from .workspace import clear_policy_allowed_commands as clear_policy_allowed_commands from .workspace import clear_policy_allowed_commands as clear_policy_allowed_commands
from .workspace import clear_workspace_allowlist as clear_workspace_allowlist from .workspace import clear_workspace_allowlist as clear_workspace_allowlist
from .workspace import clear_workspace_root as clear_workspace_root from .workspace import clear_workspace_root as clear_workspace_root
from .workspace import denylist_match as denylist_match
from .workspace import get_command_denylist as get_command_denylist from .workspace import get_command_denylist as get_command_denylist
from .workspace import get_path_denylist as get_path_denylist from .workspace import get_path_denylist as get_path_denylist
from .workspace import get_policy_confirm_timeout as get_policy_confirm_timeout from .workspace import get_policy_confirm_timeout as get_policy_confirm_timeout
from .workspace import get_workspace_root as get_workspace_root from .workspace import get_workspace_root as get_workspace_root
from .workspace import grant_policy_command as grant_policy_command from .workspace import grant_policy_command as grant_policy_command
from .workspace import granted_access_mode as granted_access_mode
from .workspace import list_workspace_allowlist as list_workspace_allowlist from .workspace import list_workspace_allowlist as list_workspace_allowlist
from .workspace import mode_covers as mode_covers
from .workspace import parse_access_mode as parse_access_mode
from .workspace import pop_owned_temporary_workspaces as pop_owned_temporary_workspaces from .workspace import pop_owned_temporary_workspaces as pop_owned_temporary_workspaces
from .workspace import remove_workspace_allowlist as remove_workspace_allowlist from .workspace import remove_workspace_allowlist as remove_workspace_allowlist
from .workspace import resolve_path as resolve_path from .workspace import resolve_path as resolve_path
@@ -107,4 +88,3 @@ from .workspace import set_path_denylist as set_path_denylist
from .workspace import set_policy_confirm_hook as set_policy_confirm_hook from .workspace import set_policy_confirm_hook as set_policy_confirm_hook
from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout
from .workspace import set_workspace_root as set_workspace_root from .workspace import set_workspace_root as set_workspace_root
from .workspace import within_workspace_roots as within_workspace_roots
-286
View File
@@ -1,286 +0,0 @@
"""Directory-access grants: request tool plus config / session / process stores.
A grant is one ``path → mode`` pair; its *source* is structural:
- :attr:`WorkspacePolicy.config_allow` — static TOML pre-allow (never prompts),
- :attr:`SessionState.access_grants` — human-approved for this session,
- :attr:`WorkspacePolicy.yolo_allow` — ``--yes`` / YOLO auto-approval, process only.
``resolve_path`` consults all three. Grants are a policy gate for path-resolving
tools, **not** a sandbox: an ``exec`` grant (or any command) can reach the rest
of the filesystem.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple
from plyngent.agent import ToolTag, tool
from plyngent.tools.context import get_session, require_session
from plyngent.tools.workspace import (
AccessMode,
denylist_match,
get_policy_confirm_timeout,
granted_access_mode,
mode_covers,
parse_access_mode,
require_bound_instance,
within_workspace_roots,
)
if TYPE_CHECKING:
from collections.abc import Callable, Mapping
from plyngent.tools.context import InstanceState, SessionState
# Max active grants across all three stores (bounds prompt/listing noise).
MAX_ACCESS_GRANTS = 32
_HOOK_KEY = "directory_access_confirm_hook"
class AccessDecision(NamedTuple):
"""Hook approval for one directory-access request.
``mode`` is the granted level (may differ from the requested one);
``persist`` is true for a session grant, false for a process-only grant.
"""
mode: AccessMode
persist: bool = True
type DirectoryAccessConfirmHook = Callable[[Path, AccessMode, str, float], AccessDecision | str | None]
def get_directory_access_confirm_hook(instance: InstanceState | None = None) -> DirectoryAccessConfirmHook | None:
"""Return the host-installed confirm hook for this instance, if any."""
inst = instance if instance is not None else require_bound_instance()
hook = inst.extras.get(_HOOK_KEY)
if hook is None or not callable(hook):
return None
def _as_hook(path: Path, mode: AccessMode, reason: str, timeout: float) -> AccessDecision | str | None:
result: object = hook(path, mode, reason, timeout)
if isinstance(result, AccessDecision | str):
return result
return None
return _as_hook
def set_directory_access_confirm_hook(
hook: DirectoryAccessConfirmHook | None,
*,
instance: InstanceState | None = None,
) -> None:
"""Install (or clear) the confirm hook the CLI uses to ask the human."""
inst = instance if instance is not None else require_bound_instance()
inst.extras[_HOOK_KEY] = hook
def _notify_access_change(session: SessionState | None) -> None:
"""Fire the host's persist hook after the live session map changes."""
if session is not None and session.on_access_change is not None:
session.on_access_change()
def grant_session_access(
path: Path | str,
mode: AccessMode,
*,
session: SessionState | None = None,
) -> Path:
"""Grant *mode* on *path* for the bound session; returns the resolved path."""
resolved = Path(path).expanduser().resolve()
sess = session if session is not None else require_session()
sess.access_grants[resolved] = mode
_notify_access_change(sess)
return resolved
def grant_process_access(path: Path | str, mode: AccessMode) -> Path:
"""Grant *mode* on *path* for this process only (``--yes``); never persisted."""
resolved = Path(path).expanduser().resolve()
require_bound_instance().workspace.yolo_allow[resolved] = mode
return resolved
def clear_process_access(*, instance: InstanceState | None = None) -> None:
"""Drop process-scoped (``--yes``) grants."""
inst = instance if instance is not None else require_bound_instance()
inst.workspace.yolo_allow.clear()
def clear_session_access(*, session: SessionState | None = None) -> None:
"""Drop live session grants (durable copy is cleared by the host)."""
sess = session if session is not None else get_session()
if sess is not None:
sess.access_grants.clear()
_notify_access_change(sess)
def active_grant_count() -> int:
"""Total live grants across config, session, and process stores."""
policy = require_bound_instance().workspace
total = len(policy.config_allow) + len(policy.yolo_allow)
session = get_session()
if session is not None:
total += len(session.access_grants)
return total
def set_config_access(
entries: Mapping[str, str],
*,
instance: InstanceState | None = None,
) -> tuple[list[Path], list[str]]:
"""Install static TOML pre-allow entries on the instance policy bag.
Replaces any previous config grants. Returns ``(applied roots, skipped
keys)``; entries are skipped when the mode is unknown or the path does not
exist (the host warns about skipped keys).
"""
inst = instance if instance is not None else require_bound_instance()
policy = inst.workspace
policy.config_allow.clear()
applied: list[Path] = []
skipped: list[str] = []
for raw_path, raw_mode in entries.items():
mode = parse_access_mode(raw_mode)
resolved: Path | None
try:
resolved = Path(raw_path).expanduser().resolve()
except OSError:
resolved = None
if mode is None or resolved is None or not resolved.exists():
skipped.append(raw_path)
continue
policy.config_allow[resolved] = mode
applied.append(resolved)
return applied, skipped
def access_grant_rows(
*,
instance: InstanceState | None = None,
session: SessionState | None = None,
) -> list[tuple[Path, AccessMode, str]]:
"""``(path, mode, source)`` rows: session, then process, then config."""
inst = instance if instance is not None else require_bound_instance()
sess = session if session is not None else get_session()
rows: list[tuple[Path, AccessMode, str]] = []
if sess is not None:
rows.extend((path, mode, "session") for path, mode in sess.access_grants.items())
rows.extend((path, mode, "process") for path, mode in inst.workspace.yolo_allow.items())
rows.extend((path, mode, "config") for path, mode in inst.workspace.config_allow.items())
return rows
def revoke_access(
path: Path | str,
*,
source: str,
instance: InstanceState | None = None,
session: SessionState | None = None,
) -> bool:
"""Remove the exact resolved *path* from one store; false if not present.
``config`` grants are owned by ``[agent].allow_paths`` and are not removed
here (the caller should point the human at the config file).
"""
resolved = Path(path).expanduser().resolve()
if source == "session":
sess = session if session is not None else get_session()
if sess is None or sess.access_grants.pop(resolved, None) is None:
return False
_notify_access_change(sess)
return True
if source == "process":
inst = instance if instance is not None else require_bound_instance()
return inst.workspace.yolo_allow.pop(resolved, None) is not None
return False
def _resolve_target(path: str) -> Path | str:
"""Resolve a requested path; return a model-facing error string on failure."""
try:
target = Path(path).expanduser().resolve()
except OSError as exc:
return f"error: cannot resolve path {path!r}: {exc}"
if not target.exists():
return f"error: path does not exist: {target}"
return target
def _precheck_target(target: Path, requested: AccessMode) -> str | None:
"""Return a short-circuit message when no prompt is needed, else ``None``."""
if requested is AccessMode.EXEC and not target.is_dir():
return f"error: exec access requires a directory: {target}"
matched = denylist_match(target, directory=target.is_dir())
if matched is not None:
return f"error: path denied by policy (matched {matched!r}); it can never be granted: {target}"
if within_workspace_roots(target):
return f"already accessible: {target} is inside the workspace"
granted = granted_access_mode(target)
if granted is not None and mode_covers(granted, requested):
return f"already granted: {target} ({granted.name.lower()})"
if active_grant_count() >= MAX_ACCESS_GRANTS:
return f"error: too many active access grants (max {MAX_ACCESS_GRANTS}); revoke some first"
return None
def _apply_decision(target: Path, decision: AccessDecision | str | None, timeout: float) -> str:
"""Record an approved grant or format the denial for the model."""
if isinstance(decision, AccessDecision):
if decision.persist and get_session() is not None:
_ = grant_session_access(target, decision.mode)
scope = "session"
else:
_ = grant_process_access(target, decision.mode)
scope = "process"
return (
f"access granted: {target} ({decision.mode.name.lower()}, {scope})\n"
"note: the path denylist still applies; the human can revoke with /grants"
)
if isinstance(decision, str) and decision.strip():
return f"error: access to {target} denied by user; user comment: {decision.strip()}"
return f"error: access to {target} denied (declined or timed out after {timeout:g}s)"
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.SESSION_STATE)
async def request_directory_access(path: str, reason: str = "", mode: str = "read") -> str:
"""Request human approval to use a directory (or exact file) outside the workspace.
Call this instead of retrying a path that failed with ``path escapes
workspace root``. ``mode`` is ``read`` (inspect files), ``write`` (also
modify files) or ``exec`` (also use it as ``run_argv`` / PTY cwd); the human
may approve a different level. Approval lasts for the session (or for this
process when ``--yes`` auto-approves). Paths matching the configured
denylist can never be granted.
"""
requested = parse_access_mode(mode)
if requested is None:
return f"error: invalid mode {mode!r} (use read, write, or exec)"
resolved = _resolve_target(path)
if isinstance(resolved, str):
return resolved
target = resolved
precheck = _precheck_target(target, requested)
if precheck is not None:
return precheck
hook = get_directory_access_confirm_hook()
if hook is None:
return f"error: access to {target} requires a human confirm (no confirm hook installed; denied)"
timeout = get_policy_confirm_timeout()
try:
decision = hook(target, requested, reason, timeout)
except Exception as exc: # noqa: BLE001 — surface confirm failures to the model
return f"error: access confirm for {target} failed: {exc}"
return _apply_decision(target, decision, timeout)
ACCESS_TOOLS = [
request_directory_access,
]
-2
View File
@@ -218,7 +218,6 @@ def _ensure_builtin_definitions_registered(catalog: ToolCatalog) -> None:
import time (usually the process catalog). Test ``catalog_scope(empty=True)`` import time (usually the process catalog). Test ``catalog_scope(empty=True)``
overrides must still see builtins for ``default_tool_definitions``. overrides must still see builtins for ``default_tool_definitions``.
""" """
from plyngent.tools.access import ACCESS_TOOLS
from plyngent.tools.chat import CHAT_TOOLS from plyngent.tools.chat import CHAT_TOOLS
from plyngent.tools.file import FILE_TOOLS from plyngent.tools.file import FILE_TOOLS
from plyngent.tools.net import NET_TOOLS from plyngent.tools.net import NET_TOOLS
@@ -227,7 +226,6 @@ def _ensure_builtin_definitions_registered(catalog: ToolCatalog) -> None:
from plyngent.tools.vcs import VCS_TOOLS from plyngent.tools.vcs import VCS_TOOLS
for definition in ( for definition in (
*ACCESS_TOOLS,
*FILE_TOOLS, *FILE_TOOLS,
*PROCESS_TOOLS, *PROCESS_TOOLS,
*VCS_TOOLS, *VCS_TOOLS,
+1 -6
View File
@@ -14,7 +14,7 @@ if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
from plyngent.agent.todo_stack import TodoStack from plyngent.agent.todo_stack import TodoStack
from plyngent.tools.workspace import AccessMode, WorkspacePolicy from plyngent.tools.workspace import WorkspacePolicy
def _default_instance_data() -> PersistentDataView[Any]: def _default_instance_data() -> PersistentDataView[Any]:
@@ -74,15 +74,10 @@ class SessionState:
todo: TodoStack | None = None todo: TodoStack | None = None
# Host hook after todo tools mutate (e.g. CLI memory persist). # Host hook after todo tools mutate (e.g. CLI memory persist).
on_todo_change: Callable[[], None] | None = None on_todo_change: Callable[[], None] | None = None
# Host hook after directory-access grants change (e.g. CLI memory persist).
on_access_change: Callable[[], None] | None = None
# Soft-confirm grants live map: tool_name → True (Phase 1 key is tool name). # Soft-confirm grants live map: tool_name → True (Phase 1 key is tool name).
# Durable copy lives under data["grants"] (see plyngent.tools.grants). # Durable copy lives under data["grants"] (see plyngent.tools.grants).
grants: dict[str, bool] = field(default_factory=dict) grants: dict[str, bool] = field(default_factory=dict)
extras: dict[str, Any] = field(default_factory=dict) extras: dict[str, Any] = field(default_factory=dict)
# Directory-access grants for this session (resolved path → mode). Config /
# process-scoped grants live on WorkspacePolicy; see plyngent.tools.access.
access_grants: dict[Path, AccessMode] = field(default_factory=dict)
def has_grant(self, tool_name: str) -> bool: def has_grant(self, tool_name: str) -> bool:
return bool(self.grants.get(tool_name)) return bool(self.grants.get(tool_name))
+3 -3
View File
@@ -4,7 +4,7 @@ import json
import shlex import shlex
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from plyngent.tools.workspace import AccessMode, WorkspaceError, resolve_path from plyngent.tools.workspace import WorkspaceError, resolve_path
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
@@ -140,7 +140,7 @@ def _write_file_reason(args: Mapping[str, object]) -> str | None:
if not isinstance(path, str) or not path: if not isinstance(path, str) or not path:
return None return None
try: try:
target = resolve_path(path, required=AccessMode.WRITE) target = resolve_path(path)
except WorkspaceError: except WorkspaceError:
# Path policy will fail later; no soft-confirm without a resolved target. # Path policy will fail later; no soft-confirm without a resolved target.
return None return None
@@ -160,7 +160,7 @@ def _copy_path_reason(args: Mapping[str, object]) -> str | None:
if not isinstance(dst, str) or not dst: if not isinstance(dst, str) or not dst:
return f"copy {src!r} → {dst!r} (overwrite)" return f"copy {src!r} → {dst!r} (overwrite)"
try: try:
target = resolve_path(dst, required=AccessMode.WRITE) target = resolve_path(dst)
except WorkspaceError: except WorkspaceError:
return None return None
if target.exists(): if target.exists():
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, lineno_read_lines, lineno_read_mtime, tool from plyngent.agent import ToolTag, lineno_read_lines, lineno_read_mtime, tool
from plyngent.tools.workspace import AccessMode, resolve_path from plyngent.tools.workspace import resolve_path
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@@ -114,7 +114,7 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st
``new_content`` may be multi-line. Use an empty string to delete the range. ``new_content`` may be multi-line. Use an empty string to delete the range.
""" """
target = resolve_path(path, required=AccessMode.WRITE) target = resolve_path(path)
if not target.is_file(): if not target.is_file():
return f"error: not a file: {path}" return f"error: not a file: {path}"
+2 -2
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import AccessMode, resolve_path from plyngent.tools.workspace import resolve_path
def _count_non_overlapping(text: str, needle: str) -> int: def _count_non_overlapping(text: str, needle: str) -> int:
@@ -60,7 +60,7 @@ async def edit_replace(path: str, old_string: str, new_string: str, max_replaces
return "error: old_string must not be empty" return "error: old_string must not be empty"
if max_replaces < 1: if max_replaces < 1:
return "error: max_replaces must be >= 1" return "error: max_replaces must be >= 1"
target = resolve_path(path, required=AccessMode.WRITE) target = resolve_path(path)
if not target.is_file(): if not target.is_file():
return f"error: not a file: {path}" return f"error: not a file: {path}"
text = target.read_text(encoding="utf-8", errors="replace") text = target.read_text(encoding="utf-8", errors="replace")
+9 -19
View File
@@ -4,7 +4,7 @@ import shutil
from pathlib import Path from pathlib import Path
from plyngent.agent import ToolTag, invalidate_lineno_read, tool from plyngent.agent import ToolTag, invalidate_lineno_read, tool
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_workspace_root, resolve_path from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
def _kind(path: Path) -> str: def _kind(path: Path) -> str:
@@ -26,28 +26,18 @@ def _remove_existing(path: Path) -> None:
def _resolve_pair(src: str, dst: str) -> tuple[Path, Path, Path] | str: def _resolve_pair(src: str, dst: str) -> tuple[Path, Path, Path] | str:
try: try:
source = resolve_path(src, required=AccessMode.READ) source = resolve_path(src)
dest = resolve_path(dst, required=AccessMode.WRITE) dest = resolve_path(dst)
root = get_workspace_root() root = get_workspace_root()
except WorkspaceError as exc: except WorkspaceError as exc:
return f"error: {exc}" return f"error: {exc}"
return source, dest, root return source, dest, root
def _display(path: Path, root: Path) -> str: def _prepare_dest(source: Path, dest: Path, root: Path, *, overwrite: bool, dst_label: str) -> Path | str:
"""Workspace-relative display path, falling back to absolute outside the root."""
try:
return str(path.relative_to(root))
except ValueError:
return str(path)
def _prepare_dest(source: Path, dest: Path, *, overwrite: bool, dst_label: str) -> Path | str:
"""Resolve copy/move destination; return error string on conflict.""" """Resolve copy/move destination; return error string on conflict."""
if source.is_file() and dest.is_dir(): if source.is_file() and dest.is_dir():
# ``dest`` may be outside the primary root (temp workspace / grant), so dest = resolve_path(str((dest / source.name).relative_to(root)))
# resolve the child directly instead of relativizing against the root.
dest = resolve_path(str(dest / source.name), required=AccessMode.WRITE)
if dest.exists() or dest.is_symlink(): if dest.exists() or dest.is_symlink():
if not overwrite: if not overwrite:
return f"error: destination exists: {dst_label} (set overwrite=true)" return f"error: destination exists: {dst_label} (set overwrite=true)"
@@ -66,7 +56,7 @@ def _copy_or_move_validated(
overwrite: bool, overwrite: bool,
action: str, action: str,
) -> str: ) -> str:
prepared = _prepare_dest(source, dest, overwrite=overwrite, dst_label=dst) prepared = _prepare_dest(source, dest, root, overwrite=overwrite, dst_label=dst)
if isinstance(prepared, str): if isinstance(prepared, str):
return prepared return prepared
dest = prepared dest = prepared
@@ -82,12 +72,12 @@ def _copy_or_move_validated(
# new. copy2/copytree preserve the source mtime, so the freshness # new. copy2/copytree preserve the source mtime, so the freshness
# check alone could miss the change — invalidate explicitly. # check alone could miss the change — invalidate explicitly.
invalidate_lineno_read(str(dest)) invalidate_lineno_read(str(dest))
return f"copied {label} {src} -> {_display(dest, root)}" return f"copied {label} {src} -> {dest.relative_to(root)}"
moved = Path(shutil.move(str(source), str(dest))).resolve() moved = Path(shutil.move(str(source), str(dest))).resolve()
# shutil.move preserves mtime too; drop read state for both paths. # shutil.move preserves mtime too; drop read state for both paths.
invalidate_lineno_read(str(source)) invalidate_lineno_read(str(source))
invalidate_lineno_read(str(moved)) invalidate_lineno_read(str(moved))
return f"moved {_kind(moved)} {src} -> {_display(moved, root)}" return f"moved {_kind(moved)} {src} -> {moved.relative_to(root)}"
except WorkspaceError as exc: except WorkspaceError as exc:
return f"error: {exc}" return f"error: {exc}"
except OSError as exc: except OSError as exc:
@@ -157,7 +147,7 @@ async def delete_path(path: str, *, recursive: bool = False) -> str:
``recursive=true`` (uses ``shutil.rmtree``). The workspace root cannot be deleted. ``recursive=true`` (uses ``shutil.rmtree``). The workspace root cannot be deleted.
""" """
try: try:
target = resolve_path(path, required=AccessMode.WRITE) target = resolve_path(path)
root = get_workspace_root() root = get_workspace_root()
except WorkspaceError as exc: except WorkspaceError as exc:
return f"error: {exc}" return f"error: {exc}"
+2 -2
View File
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.tools.file.tree import VCS_DIR_NAMES from plyngent.tools.file.tree import VCS_DIR_NAMES
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_workspace_root, resolve_path from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@@ -57,7 +57,7 @@ def _collect_glob(
def _resolve_glob_base(path: str) -> tuple[Path, Path] | str: def _resolve_glob_base(path: str) -> tuple[Path, Path] | str:
try: try:
root = get_workspace_root() root = get_workspace_root()
base = resolve_path(path, required=AccessMode.READ) base = resolve_path(path)
except WorkspaceError as exc: except WorkspaceError as exc:
return f"error: {exc}" return f"error: {exc}"
if not base.is_dir(): if not base.is_dir():
+2 -2
View File
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.tools.file.tree import VCS_DIR_NAMES from plyngent.tools.file.tree import VCS_DIR_NAMES
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_workspace_root, resolve_path from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator from collections.abc import Iterator
@@ -65,7 +65,7 @@ def _iter_files(base: Path, *, skip_hidden_dirs: bool) -> list[Path]:
def _resolve_files(path: str, *, skip_hidden_dirs: bool) -> list[Path] | str: def _resolve_files(path: str, *, skip_hidden_dirs: bool) -> list[Path] | str:
try: try:
base = resolve_path(path, required=AccessMode.READ) base = resolve_path(path)
except WorkspaceError as exc: except WorkspaceError as exc:
return f"error: {exc}" return f"error: {exc}"
if not base.exists(): if not base.exists():
+2 -2
View File
@@ -1,13 +1,13 @@
from __future__ import annotations from __future__ import annotations
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import AccessMode, resolve_path from plyngent.tools.workspace import resolve_path
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY) @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def listdir(path: str = ".") -> str: async def listdir(path: str = ".") -> str:
"""List entries in a directory under the workspace (name and type).""" """List entries in a directory under the workspace (name and type)."""
target = resolve_path(path, required=AccessMode.READ) target = resolve_path(path)
if not target.is_dir(): if not target.is_dir():
return f"error: not a directory: {path}" return f"error: not a directory: {path}"
lines: list[str] = [] lines: list[str] = []
+3 -3
View File
@@ -5,7 +5,7 @@ from typing import cast
from plyngent.agent import ToolTag, mark_lineno_read, tool from plyngent.agent import ToolTag, mark_lineno_read, tool
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.tools.truncate_token import TruncateToken, truncate_with_token, truncation_marker from plyngent.tools.truncate_token import TruncateToken, truncate_with_token, truncation_marker
from plyngent.tools.workspace import AccessMode, resolve_path from plyngent.tools.workspace import resolve_path
_LINENO_WIDTH = 6 _LINENO_WIDTH = 6
@@ -41,7 +41,7 @@ def read_raw_text(path: str) -> tuple[str | None, str]:
always refer to the raw file text (never the ``L{begin}-{end}`` header). always refer to the raw file text (never the ``L{begin}-{end}`` header).
Missing paths and directories are distinguished for the caller. Missing paths and directories are distinguished for the caller.
""" """
target = resolve_path(path, required=AccessMode.READ) target = resolve_path(path)
if not target.exists(): if not target.exists():
return None, f"error: file not found: {path}" return None, f"error: file not found: {path}"
if not target.is_file(): if not target.is_file():
@@ -72,7 +72,7 @@ async def read_file(
so ``get_truncated`` can continue reading the rest without a new request. so ``get_truncated`` can continue reading the rest without a new request.
Pass ``max_chars < 1`` to read unbounded. Pass ``max_chars < 1`` to read unbounded.
""" """
target = resolve_path(path, required=AccessMode.READ) target = resolve_path(path)
text, err = read_raw_text(path) text, err = read_raw_text(path)
if err: if err:
return err return err
+2 -2
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_path_denylist, resolve_path from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Sequence from collections.abc import Sequence
@@ -270,7 +270,7 @@ async def tree(
return "error: max_entries must be >= 1" return "error: max_entries must be >= 1"
try: try:
origin = resolve_path(path, required=AccessMode.READ) origin = resolve_path(path)
except WorkspaceError as exc: except WorkspaceError as exc:
return f"error: {exc}" return f"error: {exc}"
if not origin.is_dir(): if not origin.is_dir():
+2 -2
View File
@@ -1,13 +1,13 @@
from __future__ import annotations from __future__ import annotations
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import AccessMode, resolve_path from plyngent.tools.workspace import resolve_path
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO) @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def write_file(path: str, content: str) -> str: async def write_file(path: str, content: str) -> str:
"""Write text content to a file under the workspace (creates parents).""" """Write text content to a file under the workspace (creates parents)."""
target = resolve_path(path, required=AccessMode.WRITE) target = resolve_path(path)
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
_ = target.write_text(content, encoding="utf-8") _ = target.write_text(content, encoding="utf-8")
# The write changes the file's mtime, so edit_lineno's freshness check # The write changes the file's mtime, so edit_lineno's freshness check
+1 -2
View File
@@ -10,7 +10,6 @@ if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
from plyngent.tools.workspace import ( from plyngent.tools.workspace import (
AccessMode,
WorkspaceError, WorkspaceError,
check_command_allowed, check_command_allowed,
get_workspace_root, get_workspace_root,
@@ -62,7 +61,7 @@ def format_command_result(
def resolve_workdir(cwd: str) -> tuple["Path", str]: def resolve_workdir(cwd: str) -> tuple["Path", str]:
"""Return (absolute workdir, display path relative to workspace when possible).""" """Return (absolute workdir, display path relative to workspace when possible)."""
workdir = resolve_path(cwd, required=AccessMode.EXEC) workdir = resolve_path(cwd)
if not workdir.is_dir(): if not workdir.is_dir():
msg = f"not a directory: {cwd}" msg = f"not a directory: {cwd}"
raise WorkspaceError(msg) raise WorkspaceError(msg)
+2 -2
View File
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, ClassVar, cast
from plyngent.tools.process.pty_backend import PtyHandle, pty_available, spawn_pty from plyngent.tools.process.pty_backend import PtyHandle, pty_available, spawn_pty
from plyngent.tools.process.pty_terminal import sanitize_pty_output_for_tool from plyngent.tools.process.pty_terminal import sanitize_pty_output_for_tool
from plyngent.tools.workspace import AccessMode, WorkspaceError, check_command_allowed, resolve_path from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@@ -122,7 +122,7 @@ class PtyManager:
msg = "PTY is not available on this platform (Windows needs pywinpty)" msg = "PTY is not available on this platform (Windows needs pywinpty)"
raise WorkspaceError(msg) raise WorkspaceError(msg)
check_command_allowed(command) check_command_allowed(command)
workdir = resolve_path(cwd, required=AccessMode.EXEC) workdir = resolve_path(cwd)
if not workdir.is_dir(): if not workdir.is_dir():
msg = f"cwd is not a directory: {cwd}" msg = f"cwd is not a directory: {cwd}"
raise WorkspaceError(msg) raise WorkspaceError(msg)
+12 -99
View File
@@ -1,14 +1,13 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import IntEnum
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Mapping, Sequence from collections.abc import Callable, Sequence
from plyngent.tools.context import InstanceState, SessionState from plyngent.tools.context import InstanceState
DEFAULT_COMMAND_DENYLIST: frozenset[str] = frozenset( DEFAULT_COMMAND_DENYLIST: frozenset[str] = frozenset(
{ {
@@ -40,36 +39,6 @@ MAX_TEMPORARY_WORKSPACES = 16
# Timed human override for command denylist (independent of YOLO soft-confirm). # Timed human override for command denylist (independent of YOLO soft-confirm).
DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS = 30.0 DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS = 30.0
class AccessMode(IntEnum):
"""Ordered access level for path grants outside the workspace root.
A grant satisfies a requirement when its value is ``>=`` the requirement
(``read`` < ``write`` < ``exec``); ``exec`` also implies file writes.
"""
READ = 1
WRITE = 2
EXEC = 3
_ACCESS_MODE_NAMES: dict[str, AccessMode] = {
"read": AccessMode.READ,
"write": AccessMode.WRITE,
"exec": AccessMode.EXEC,
}
def parse_access_mode(value: str) -> AccessMode | None:
"""Parse a case-insensitive mode token (``read`` / ``write`` / ``exec``)."""
return _ACCESS_MODE_NAMES.get(value.strip().lower())
def mode_covers(granted: AccessMode, required: AccessMode) -> bool:
"""Whether a grant at *granted* satisfies a tool that needs *required*."""
return granted >= required
# Hook: (basename, argv, timeout_seconds) -> True allow for this session basename. # Hook: (basename, argv, timeout_seconds) -> True allow for this session basename.
type PolicyConfirmHook = Callable[[str, Sequence[str], float], bool] type PolicyConfirmHook = Callable[[str, Sequence[str], float], bool]
@@ -94,10 +63,6 @@ class WorkspacePolicy:
policy_allowed_commands: set[str] = field(default_factory=set) policy_allowed_commands: set[str] = field(default_factory=set)
policy_confirm_hook: PolicyConfirmHook | None = None policy_confirm_hook: PolicyConfirmHook | None = None
policy_confirm_timeout_seconds: float = DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS policy_confirm_timeout_seconds: float = DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
# Static TOML pre-allow (resolved path → mode); never prompts.
config_allow: dict[Path, AccessMode] = field(default_factory=dict)
# Process-scoped YOLO / --yes grants (resolved path → mode); never persisted.
yolo_allow: dict[Path, AccessMode] = field(default_factory=dict)
def _bound_instance() -> InstanceState | None: def _bound_instance() -> InstanceState | None:
@@ -106,12 +71,6 @@ def _bound_instance() -> InstanceState | None:
return get_instance() return get_instance()
def _bound_session() -> SessionState | None:
from plyngent.tools.context import get_session
return get_session()
def require_bound_instance() -> InstanceState: def require_bound_instance() -> InstanceState:
"""Return the bound instance or raise :class:`WorkspaceError`.""" """Return the bound instance or raise :class:`WorkspaceError`."""
instance = _bound_instance() instance = _bound_instance()
@@ -284,55 +243,11 @@ def _under_any_root(resolved: Path, instance: InstanceState, policy: WorkspacePo
return False return False
def granted_access_mode(resolved: Path) -> AccessMode | None: def resolve_path(path: str | Path) -> Path:
"""Highest config / session / process mode covering *resolved*, if any.""" """Resolve ``path`` under the workspace root or an allowlisted temp root.
policy = active_workspace_policy()
session = _bound_session()
buckets: list[Mapping[Path, AccessMode]] = [policy.config_allow, policy.yolo_allow]
if session is not None:
buckets.append(session.access_grants)
best: AccessMode | None = None
for grants in buckets:
for root, granted in grants.items():
if (resolved == root or resolved.is_relative_to(root)) and (best is None or granted > best):
best = granted
return best
def _path_grant_covers(resolved: Path, required: AccessMode) -> bool:
"""Whether a config / session / process grant covers *resolved*."""
granted = granted_access_mode(resolved)
return granted is not None and mode_covers(granted, required)
def within_workspace_roots(resolved: Path) -> bool:
"""Whether *resolved* is under the primary root(s) or a temp allowlist root."""
instance = require_bound_instance()
return _under_any_root(resolved, instance, instance.workspace)
def denylist_match(resolved: Path, *, directory: bool = False) -> str | None:
"""Return the path denylist pattern matching *resolved*, or ``None``.
*directory* also matches a trailing-separator pattern (``/secret/``) against
the directory path itself, not just its children.
"""
resolved_str = str(resolved).replace("\\", "/")
if directory and not resolved_str.endswith("/"):
resolved_str += "/"
for pattern in active_workspace_policy().path_denylist:
if pattern and pattern.replace("\\", "/") in resolved_str:
return pattern
return None
def resolve_path(path: str | Path, *, required: AccessMode = AccessMode.WRITE) -> Path:
"""Resolve ``path`` under the workspace root, an allowlist root, or a grant.
Relative paths resolve against the **primary** workspace root. Absolute Relative paths resolve against the **primary** workspace root. Absolute
paths may also land under a temporary workspace allowlist entry or a paths may also land under a temporary workspace allowlist entry.
directory-access grant; ``required`` is the access mode the caller needs
(default :attr:`AccessMode.WRITE`, the conservative choice).
""" """
instance = require_bound_instance() instance = require_bound_instance()
policy = instance.workspace policy = instance.workspace
@@ -341,17 +256,15 @@ def resolve_path(path: str | Path, *, required: AccessMode = AccessMode.WRITE) -
if not candidate.is_absolute(): if not candidate.is_absolute():
candidate = root / candidate candidate = root / candidate
resolved = candidate.expanduser().resolve() resolved = candidate.expanduser().resolve()
if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required): if not _under_any_root(resolved, instance, policy):
msg = ( msg = f"path escapes workspace root ({root}): {path}"
f"path escapes workspace root ({root}): {path}; "
"call request_directory_access to request access to a directory outside the workspace"
)
raise WorkspaceError(msg) raise WorkspaceError(msg)
# Normalize separators so denylist entries like ``/secrets/`` match on Windows. # Normalize separators so denylist entries like ``/secrets/`` match on Windows.
matched = denylist_match(resolved) resolved_str = str(resolved).replace("\\", "/")
if matched is not None: for pattern in policy.path_denylist:
msg = f"path denied by policy (matched {matched!r}): {path}" if pattern and pattern.replace("\\", "/") in resolved_str:
raise WorkspaceError(msg) msg = f"path denied by policy (matched {pattern!r}): {path}"
raise WorkspaceError(msg)
return resolved return resolved
-156
View File
@@ -1,156 +0,0 @@
"""CLI slash ``/grants``, session hydrate/persist, and the access confirm hook."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
import tomlkit
from plyngent.cli.slash import handle_slash
from plyngent.cli.state import ReplState
from plyngent.config.models import DatabaseConfig, OpenAIProvider
from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore
from plyngent.prompting import NonInteractiveBackend, temporary_backend
from plyngent.tools import AccessMode, grant_session_access
from plyngent.tools.access import AccessDecision
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from pathlib import Path
async def _new_state(
tmp_path: Path,
*,
document: object | None = None,
) -> tuple[ReplState, MemoryStore]:
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(
path=tmp_path / "plyngent.toml",
document=tomlkit.document() if document is None else document,
)
config.providers = {"local": provider}
state = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=False,
)
await state.new_session("t")
return state, memory
@pytest.fixture
async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
st, memory = await _new_state(tmp_path)
yield st
await memory.close()
async def test_grants_list_empty(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/grants") is True
assert "no directory-access grants" in capsys.readouterr().out
async def test_grants_list_and_revoke_session(
state: ReplState,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
outside = (tmp_path.parent / "outside-grant").resolve()
outside.mkdir(exist_ok=True)
_ = grant_session_access(outside, AccessMode.READ, session=state.session_state)
state.instance_state.workspace.yolo_allow[outside] = AccessMode.EXEC
state.instance_state.workspace.config_allow[tmp_path] = AccessMode.READ
assert await handle_slash(state, "/grants list") is True
out = capsys.readouterr().out
assert "directory-access grants (3)" in out
assert "session" in out
assert "process" in out
assert "config" in out
# Rows are ordered session, process, config.
assert await handle_slash(state, "/grants revoke 0") is True
assert "revoked" in capsys.readouterr().out
assert state.session_state.access_grants == {}
assert await state.memory.get_session_access_grants(state.session_id) == {}
async def test_grants_revoke_all_keeps_config(
state: ReplState,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
outside = (tmp_path.parent / "outside-all").resolve()
outside.mkdir(exist_ok=True)
_ = grant_session_access(outside, AccessMode.READ, session=state.session_state)
state.instance_state.workspace.yolo_allow[outside] = AccessMode.WRITE
state.instance_state.workspace.config_allow[tmp_path] = AccessMode.READ
assert await handle_slash(state, "/grants revoke all") is True
assert "config grants unchanged" in capsys.readouterr().out
assert state.session_state.access_grants == {}
assert state.instance_state.workspace.yolo_allow == {}
assert state.instance_state.workspace.config_allow == {tmp_path: AccessMode.READ}
async def test_grants_revoke_config_points_at_toml(
state: ReplState,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
state.instance_state.workspace.config_allow[tmp_path] = AccessMode.READ
assert await handle_slash(state, "/grants revoke 0") is True
assert "edit [agent].allow_paths" in capsys.readouterr().out
assert state.instance_state.workspace.config_allow == {tmp_path: AccessMode.READ}
async def test_grants_revoke_bad_index(
state: ReplState,
capsys: pytest.CaptureFixture[str],
) -> None:
assert await handle_slash(state, "/grants revoke 99") is True
assert "no grant at index 99" in capsys.readouterr().err
async def test_persist_and_load_roundtrip(state: ReplState, tmp_path: Path) -> None:
outside = (tmp_path.parent / "outside-hydrate").resolve()
outside.mkdir(exist_ok=True)
_ = grant_session_access(outside, AccessMode.WRITE, session=state.session_state)
await state.persist_access_grants()
state.session_state.access_grants.clear()
await state.load_access_grants()
assert state.session_state.access_grants == {outside: AccessMode.WRITE}
def test_yolo_hook_auto_approves_as_process_grant(state: ReplState, tmp_path: Path) -> None:
state.set_yolo("on")
decision = state.directory_access_confirm_hook(tmp_path, AccessMode.EXEC, "", 30.0)
assert decision == AccessDecision(AccessMode.EXEC, persist=False)
def test_default_hook_denies_noninteractive(state: ReplState, tmp_path: Path) -> None:
with temporary_backend(NonInteractiveBackend()):
assert state.directory_access_confirm_hook(tmp_path, AccessMode.READ, "", 1.0) is None
async def test_config_allow_paths_installed(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
outside = (tmp_path.parent / "outside-config").resolve()
outside.mkdir(exist_ok=True)
document = tomlkit.parse(f'[agent]\nallow_paths = {{ "{outside}" = "read", "/does/not/exist" = "read" }}\n')
state, memory = await _new_state(tmp_path, document=document)
try:
assert state.instance_state.workspace.config_allow == {outside: AccessMode.READ}
assert "ignored allow_paths entries" in capsys.readouterr().err
finally:
await memory.close()
+9 -13
View File
@@ -16,8 +16,6 @@ from plyngent.cli.interrupt import (
) )
from plyngent.cli.limits import prompt_continue_limit, prompt_continue_limit_async from plyngent.cli.limits import prompt_continue_limit, prompt_continue_limit_async
from plyngent.cli.retry import run_cancellable from plyngent.cli.retry import run_cancellable
from plyngent.prompting import temporary_backend
from tests.test_prompting import ScriptedBackend
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@@ -41,16 +39,12 @@ def test_nested_pause_depth() -> None:
def test_prompt_continue_limit_under_pause(monkeypatch: pytest.MonkeyPatch) -> None: def test_prompt_continue_limit_under_pause(monkeypatch: pytest.MonkeyPatch) -> None:
backend = ScriptedBackend(["y"]) def _confirm(*_a: object, **_k: object) -> bool:
def _read_line(prompt: str, *, default: str | None = None, completions: object = None) -> str:
del prompt, default, completions
assert allow_task_cancel() is False assert allow_task_cancel() is False
return "y" return True
monkeypatch.setattr(backend, "read_line", _read_line) monkeypatch.setattr("click.confirm", _confirm)
with temporary_backend(backend): assert prompt_continue_limit("too many rounds") is True
assert prompt_continue_limit("too many rounds") is True
async def test_run_in_prompt_thread_pauses_cancel() -> None: async def test_run_in_prompt_thread_pauses_cancel() -> None:
@@ -66,9 +60,11 @@ async def test_run_in_prompt_thread_pauses_cancel() -> None:
async def test_prompt_continue_limit_async(monkeypatch: pytest.MonkeyPatch) -> None: async def test_prompt_continue_limit_async(monkeypatch: pytest.MonkeyPatch) -> None:
del monkeypatch def _confirm(*_a: object, **_k: object) -> bool:
with temporary_backend(ScriptedBackend(["y"])): return True
assert await prompt_continue_limit_async("too many rounds") is True
monkeypatch.setattr("click.confirm", _confirm)
assert await prompt_continue_limit_async("too many rounds") is True
async def test_sigint_cancels_after_prompt_pause() -> None: async def test_sigint_cancels_after_prompt_pause() -> None:
+3 -115
View File
@@ -1,106 +1,28 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from plyngent.cli.limits import ( from plyngent.cli.limits import (
auto_continue_enabled,
format_tool_confirm_box, format_tool_confirm_box,
install_cli_limit_hooks, install_cli_limit_hooks,
prompt_confirm_tool, prompt_confirm_tool,
prompt_continue_limit, prompt_continue_limit,
reset_auto_continue,
reset_auto_continue_turn,
set_auto_continue_default,
) )
from plyngent.prompting import NonInteractiveBackend, get_prompt_backend, temporary_backend from plyngent.prompting import get_prompt_backend, temporary_backend
from plyngent.tools.process.pty_session import PtyManager from plyngent.tools.process.pty_session import PtyManager
from tests.test_prompting import ScriptedBackend from tests.test_prompting import ScriptedBackend
if TYPE_CHECKING:
from collections.abc import Iterator
@pytest.fixture
def clean_auto_continue() -> Iterator[None]:
reset_auto_continue()
yield
reset_auto_continue()
def test_prompt_continue_limit_yes() -> None: def test_prompt_continue_limit_yes() -> None:
backend = ScriptedBackend(["y"]) backend = ScriptedBackend([], confirms=[True])
with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is True
def test_prompt_continue_limit_default_yes() -> None:
backend = ScriptedBackend([])
with temporary_backend(backend): with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is True assert prompt_continue_limit("hit a wall") is True
def test_prompt_continue_limit_no() -> None: def test_prompt_continue_limit_no() -> None:
backend = ScriptedBackend(["n"]) backend = ScriptedBackend([], confirms=[False])
with temporary_backend(backend): with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is False assert prompt_continue_limit("hit a wall") is False
def test_prompt_continue_limit_yyy_sets_turn_skip(clean_auto_continue: None) -> None:
del clean_auto_continue
backend = ScriptedBackend(["yyy"])
with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is True
assert auto_continue_enabled() is True
# No further prompt: the script is empty and must not be consumed.
assert prompt_continue_limit("hit a wall again") is True
def test_reset_auto_continue_turn_clears_yyy(clean_auto_continue: None) -> None:
del clean_auto_continue
with temporary_backend(ScriptedBackend(["yyy"])):
assert prompt_continue_limit("limit") is True
reset_auto_continue_turn()
assert auto_continue_enabled() is False
with temporary_backend(ScriptedBackend(["n"])):
assert prompt_continue_limit("limit") is False
def test_auto_continue_default_short_circuits_prompt(clean_auto_continue: None) -> None:
del clean_auto_continue
set_auto_continue_default(enabled=True)
assert auto_continue_enabled() is True
with temporary_backend(NonInteractiveBackend()):
assert prompt_continue_limit("limit") is True
def test_prompt_continue_limit_noninteractive_denies() -> None:
with temporary_backend(NonInteractiveBackend()):
assert prompt_continue_limit("limit") is False
def test_setup_hooks_installs_pty_hook_for_auto_continue(clean_auto_continue: None) -> None:
del clean_auto_continue
from plyngent.cli.app import _setup_hooks
_setup_hooks(interactive=False, auto_continue=True)
assert PtyManager._limit_continue is not None
_setup_hooks(interactive=False, auto_continue=False)
assert PtyManager._limit_continue is None
def test_pty_offer_raise_uses_auto_continue(clean_auto_continue: None) -> None:
del clean_auto_continue
PtyManager.set_limit_continue_hook(prompt_continue_limit)
set_auto_continue_default(enabled=True)
try:
with temporary_backend(NonInteractiveBackend()):
assert PtyManager._offer_raise("PTY session limit reached") is True
finally:
PtyManager.set_limit_continue_hook(None)
def test_prompt_confirm_tool_default_deny() -> None: def test_prompt_confirm_tool_default_deny() -> None:
backend = ScriptedBackend([], confirms=[False]) backend = ScriptedBackend([], confirms=[False])
with temporary_backend(backend): with temporary_backend(backend):
@@ -132,37 +54,3 @@ def test_policy_confirm_noninteractive_denies() -> None:
with temporary_backend(NonInteractiveBackend()): with temporary_backend(NonInteractiveBackend()):
assert prompt_policy_command_confirm("sudo", ["sudo", "id"], 1.0) is False assert prompt_policy_command_confirm("sudo", ["sudo", "id"], 1.0) is False
def test_directory_access_prompt_noninteractive_denies() -> None:
from pathlib import Path
from plyngent.cli.limits import prompt_directory_access_confirm
from plyngent.prompting import NonInteractiveBackend
from plyngent.tools.workspace import AccessMode
with temporary_backend(NonInteractiveBackend()):
assert prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 1.0) is None
def test_directory_access_prompt_yes_and_level(monkeypatch: pytest.MonkeyPatch) -> None:
from pathlib import Path
from plyngent.cli import limits
from plyngent.tools.access import AccessDecision
from plyngent.tools.workspace import AccessMode
target = Path("/tmp")
with temporary_backend(ScriptedBackend([])):
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "y\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
AccessMode.READ
)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "exec\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
AccessMode.EXEC
)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "n\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: None)
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
-26
View File
@@ -94,32 +94,6 @@ async def test_help_and_clear(state: ReplState) -> None:
assert state.agent.messages == [] assert state.agent.messages == []
async def test_auto_continue_limits_installs_on_limit(tmp_path: Path) -> None:
from plyngent.cli.limits import auto_continue_enabled, reset_auto_continue
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
config.providers = {"local": provider}
try:
st = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=False,
interactive_limits=False,
auto_continue_limits=True,
)
assert auto_continue_enabled() is True
assert st.agent.on_limit is not None
finally:
reset_auto_continue()
await memory.close()
async def test_help_command_usage_line(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None: async def test_help_command_usage_line(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/help compact") is True assert await handle_slash(state, "/help compact") is True
out = capsys.readouterr().out out = capsys.readouterr().out
-39
View File
@@ -30,8 +30,6 @@ from plyngent.memory import MemoryStore
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from plyngent.agent import AgentEvent
def test_default_retry_delays_schedule() -> None: def test_default_retry_delays_schedule() -> None:
assert DEFAULT_MAX_AUTO_RETRIES == 10 assert DEFAULT_MAX_AUTO_RETRIES == 10
@@ -298,43 +296,6 @@ async def test_second_ctrl_c_during_retry_announce_stays_benign(
assert await _wait_for_retry(1, 3, 0.01) is True assert await _wait_for_retry(1, 3, 0.01) is True
async def test_turn_boundaries_reset_auto_continue() -> None:
from plyngent.cli.limits import (
auto_continue_enabled,
prompt_continue_limit,
reset_auto_continue,
)
from plyngent.prompting import temporary_backend
from tests.test_prompting import ScriptedBackend
reset_auto_continue()
try:
with temporary_backend(ScriptedBackend(["yyy"])):
assert prompt_continue_limit("limit") is True
assert auto_continue_enabled() is True
store = await MemoryStore.open(DatabaseConfig())
try:
session = await store.create_session(name="t")
client = FlakyClient(fail_times=0)
agent = ChatAgent(client, model="m", memory=store, session_id=session.sid)
seen: list[bool] = []
async def starter() -> AsyncIterator[AgentEvent]:
seen.append(auto_continue_enabled())
async for event in agent.run("hi"):
yield event
ok = await run_turn_with_retries(agent, starter=starter, delays=())
assert ok is True
assert seen == [False] # cleared before the turn starts
assert auto_continue_enabled() is False # cleared after the turn ends
finally:
await store.close()
finally:
reset_auto_continue()
async def test_manual_retry_after_exhausted(monkeypatch: pytest.MonkeyPatch) -> None: async def test_manual_retry_after_exhausted(monkeypatch: pytest.MonkeyPatch) -> None:
async def no_wait(_seconds: float) -> bool: async def no_wait(_seconds: float) -> bool:
return True return True
-31
View File
@@ -26,8 +26,6 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
assert store.agent_config.parallel_tools is True assert store.agent_config.parallel_tools is True
assert store.agent_config.confirm_destructive is True assert store.agent_config.confirm_destructive is True
assert store.agent_config.path_denylist == [] assert store.agent_config.path_denylist == []
assert store.agent_config.allow_paths == {}
assert store.agent_config.auto_continue_limits is False
assert store.agent_config.max_context_tokens == 200_000 assert store.agent_config.max_context_tokens == 200_000
assert store.plugins_config.enable == [] assert store.plugins_config.enable == []
assert store.plugins_config.disable == [] assert store.plugins_config.disable == []
@@ -84,7 +82,6 @@ parallel_tools = false
confirm_destructive = false confirm_destructive = false
path_denylist = ["/secrets/", ".ssh/"] path_denylist = ["/secrets/", ".ssh/"]
max_context_tokens = 5000 max_context_tokens = 5000
auto_continue_limits = true
""", """,
encoding="utf-8", encoding="utf-8",
) )
@@ -96,7 +93,6 @@ auto_continue_limits = true
assert store.agent_config.confirm_destructive is False assert store.agent_config.confirm_destructive is False
assert store.agent_config.path_denylist == ["/secrets/", ".ssh/"] assert store.agent_config.path_denylist == ["/secrets/", ".ssh/"]
assert store.agent_config.max_context_tokens == 5000 assert store.agent_config.max_context_tokens == 5000
assert store.agent_config.auto_continue_limits is True
composed = compose_agent_system_content( composed = compose_agent_system_content(
store.agent_config.system_prompt, store.agent_config.system_prompt,
store.agent_config.tool_directives, store.agent_config.tool_directives,
@@ -166,33 +162,6 @@ tool_directives = ""
) )
def test_agent_allow_paths_parse(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[agent]
allow_paths = { "/data/datasets" = "read", "/tmp/build" = "exec" }
""",
encoding="utf-8",
)
store = load(path)
assert store.agent_config.allow_paths == {"/data/datasets": "read", "/tmp/build": "exec"}
def test_agent_allow_paths_invalid_mode_falls_back(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[agent]
allow_paths = { "/data" = "sudo" }
""",
encoding="utf-8",
)
store = load(path)
# Invalid field value resets the whole [agent] section to defaults.
assert store.agent_config.allow_paths == {}
def test_agent_mcp_instructions_flag(tmp_path: Path) -> None: def test_agent_mcp_instructions_flag(tmp_path: Path) -> None:
path = tmp_path / "c.toml" path = tmp_path / "c.toml"
_ = path.write_text( _ = path.write_text(
+8 -23
View File
@@ -34,21 +34,6 @@ async def test_create_session_uses_default_user(store: MemoryStore) -> None:
assert session.uid == user.uid assert session.uid == user.uid
async def test_session_access_grants_roundtrip(store: MemoryStore) -> None:
session = await store.create_session(name="grants")
assert await store.get_session_access_grants(session.sid) == {}
_ = await store.update_session_access_grants(
session.sid,
{"/data/datasets": "read", "/tmp/build": "exec"},
)
assert await store.get_session_access_grants(session.sid) == {
"/data/datasets": "read",
"/tmp/build": "exec",
}
_ = await store.update_session_access_grants(session.sid, {})
assert await store.get_session_access_grants(session.sid) == {}
async def test_session_context_usage_roundtrip(store: MemoryStore) -> None: async def test_session_context_usage_roundtrip(store: MemoryStore) -> None:
session = await store.create_session(name="usage") session = await store.create_session(name="usage")
row = await store.update_session_context_usage( row = await store.update_session_context_usage(
@@ -308,9 +293,9 @@ async def test_schema_migrations_stamp_version(tmp_path: object) -> None:
db_path = tmp_path / "fresh.db" db_path = tmp_path / "fresh.db"
store = await MemoryStore.open(DatabaseConfig(url=str(db_path))) store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close() await store.close()
assert await _user_version(db_path) == 4 assert await _user_version(db_path) == 3
columns = await _session_columns(db_path) columns = await _session_columns(db_path)
assert {"workspace", "provider_name", "model", "todo_stack", "access_grants", "last_prompt_tokens"} <= columns assert {"workspace", "provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
async def test_legacy_db_migrated_to_latest(tmp_path: object) -> None: async def test_legacy_db_migrated_to_latest(tmp_path: object) -> None:
@@ -326,9 +311,9 @@ async def test_legacy_db_migrated_to_latest(tmp_path: object) -> None:
session = await store.create_session(name="migrated") session = await store.create_session(name="migrated")
await store.close() await store.close()
assert await _user_version(db_path) == 4 assert await _user_version(db_path) == 3
columns = await _session_columns(db_path) columns = await _session_columns(db_path)
assert {"workspace", "provider_name", "model", "todo_stack", "access_grants", "last_prompt_tokens"} <= columns assert {"workspace", "provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
assert session.sid is not None assert session.sid is not None
@@ -350,10 +335,10 @@ async def test_partially_migrated_legacy_db_noops_remaining_steps(tmp_path: obje
session = await store.create_session(name="partial", workspace=tmp_path) session = await store.create_session(name="partial", workspace=tmp_path)
await store.close() await store.close()
assert await _user_version(db_path) == 4 assert await _user_version(db_path) == 3
columns = await _session_columns(db_path) columns = await _session_columns(db_path)
assert "workspace" in columns assert "workspace" in columns
assert {"provider_name", "model", "todo_stack", "access_grants", "last_prompt_tokens"} <= columns assert {"provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
assert session.workspace is not None assert session.workspace is not None
@@ -365,8 +350,8 @@ async def test_stamped_db_skips_migrations_on_reopen(tmp_path: object) -> None:
db_path = tmp_path / "stamped.db" db_path = tmp_path / "stamped.db"
store = await MemoryStore.open(DatabaseConfig(url=str(db_path))) store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close() await store.close()
assert await _user_version(db_path) == 4 assert await _user_version(db_path) == 3
store = await MemoryStore.open(DatabaseConfig(url=str(db_path))) store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close() await store.close()
assert await _user_version(db_path) == 4 assert await _user_version(db_path) == 3
-263
View File
@@ -1,263 +0,0 @@
"""``request_directory_access`` tool and the grant stores."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from plyngent.agent import ToolTag
from plyngent.tools import (
MAX_ACCESS_GRANTS,
AccessDecision,
AccessMode,
SessionState,
WorkspaceError,
active_workspace_policy,
get_directory_access_confirm_hook,
grant_session_access,
request_directory_access,
resolve_path,
set_config_access,
set_directory_access_confirm_hook,
set_path_denylist,
)
from plyngent.tools.context import bind_session
from tests.test_tools.helpers import call_async
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
@pytest.fixture
def session() -> Iterator[SessionState]:
state = SessionState()
with bind_session(state):
yield state
def _never_called(*_args: object) -> AccessDecision | str | None:
msg = "confirm hook should not be called"
raise AssertionError(msg)
def test_tool_tags_and_registry() -> None:
tags = request_directory_access.tags
assert tags & ToolTag.LOCAL
assert tags & ToolTag.INSTANCE_STATE
assert tags & ToolTag.SESSION_STATE
assert not (tags & ToolTag.READ_ONLY)
from plyngent.tools.catalog import default_tool_definitions
assert "request_directory_access" in {definition.name for definition in default_tool_definitions()}
def test_hook_roundtrip(workspace: object, session: SessionState) -> None:
del workspace, session
assert get_directory_access_confirm_hook() is None
set_directory_access_confirm_hook(_never_called)
assert get_directory_access_confirm_hook() is not None
set_directory_access_confirm_hook(None)
assert get_directory_access_confirm_hook() is None
async def test_approve_grants_session_access(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("approved").resolve()
calls: list[tuple[Path, AccessMode, str, float]] = []
def hook(path: Path, mode: AccessMode, reason: str, timeout: float) -> AccessDecision:
calls.append((path, mode, reason, timeout))
return AccessDecision(AccessMode.WRITE)
set_directory_access_confirm_hook(hook)
out = await call_async(request_directory_access, str(outside), "need the dataset", "read")
assert "access granted" in out
assert "write, session" in out
assert calls == [(outside, AccessMode.READ, "need the dataset", 30.0)]
assert session.access_grants[outside] == AccessMode.WRITE
target = outside / "data.txt"
_ = target.write_text("x", encoding="utf-8")
assert resolve_path(str(target), required=AccessMode.WRITE) == target
async def test_process_grant_when_not_persisting(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("process").resolve()
set_directory_access_confirm_hook(lambda *_a: AccessDecision(AccessMode.READ, persist=False))
out = await call_async(request_directory_access, str(outside), mode="read")
assert "read, process" in out
assert session.access_grants == {}
assert active_workspace_policy().yolo_allow[outside] == AccessMode.READ
async def test_deny_with_comment(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("denied").resolve()
set_directory_access_confirm_hook(lambda *_a: "no, that is my tax data")
out = await call_async(request_directory_access, str(outside))
assert out.startswith("error: access to")
assert "user comment: no, that is my tax data" in out
assert session.access_grants == {}
async def test_deny_none(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("timeout").resolve()
set_directory_access_confirm_hook(lambda *_a: None)
out = await call_async(request_directory_access, str(outside))
assert "denied (declined or timed out" in out
assert session.access_grants == {}
async def test_no_hook_denies(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("nohook").resolve()
set_directory_access_confirm_hook(None)
out = await call_async(request_directory_access, str(outside))
assert "no confirm hook installed" in out
async def test_denylist_can_never_be_granted(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("secret-zone").resolve()
secret = outside / "secret"
secret.mkdir()
set_path_denylist(["/secret/"])
set_directory_access_confirm_hook(_never_called)
try:
out = await call_async(request_directory_access, str(secret), mode="read")
assert "can never be granted" in out
assert session.access_grants == {}
finally:
set_path_denylist(None)
async def test_already_accessible_inside_workspace(
workspace: object,
session: SessionState,
) -> None:
from pathlib import Path
assert isinstance(workspace, Path)
set_directory_access_confirm_hook(_never_called)
out = await call_async(request_directory_access, str(workspace), mode="read")
assert "already accessible" in out
assert session.access_grants == {}
async def test_already_granted_skips_prompt(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("granted").resolve()
_ = grant_session_access(outside, AccessMode.READ)
set_directory_access_confirm_hook(_never_called)
out = await call_async(request_directory_access, str(outside), mode="read")
assert "already granted" in out
async def test_invalid_mode(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace, session
outside = tmp_path_factory.mktemp("mode").resolve()
out = await call_async(request_directory_access, str(outside), mode="sudo")
assert "invalid mode" in out
async def test_exec_requires_directory(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace, session
outside = tmp_path_factory.mktemp("exec-file").resolve()
target = outside / "f.txt"
_ = target.write_text("x", encoding="utf-8")
out = await call_async(request_directory_access, str(target), mode="exec")
assert "exec access requires a directory" in out
async def test_missing_path(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace, session
missing = tmp_path_factory.mktemp("missing").resolve() / "nope"
out = await call_async(request_directory_access, str(missing))
assert "does not exist" in out
async def test_grant_cap(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("cap").resolve()
policy = active_workspace_policy()
for index in range(MAX_ACCESS_GRANTS):
policy.config_allow[outside / f"d{index}"] = AccessMode.READ
set_directory_access_confirm_hook(_never_called)
try:
out = await call_async(request_directory_access, str(outside))
assert "too many active access grants" in out
assert session.access_grants == {}
finally:
policy.config_allow.clear()
def test_set_config_access_installs_and_skips(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace, session
outside = tmp_path_factory.mktemp("config-allow").resolve()
target = outside / "data.txt"
_ = target.write_text("x", encoding="utf-8")
missing = outside / "missing"
applied, skipped = set_config_access(
{
str(outside): "read",
str(missing): "read",
str(target): "sudo",
}
)
assert applied == [outside]
assert sorted(skipped) == sorted([str(missing), str(target)])
assert active_workspace_policy().config_allow == {outside: AccessMode.READ}
assert resolve_path(str(target), required=AccessMode.READ) == target
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(target), required=AccessMode.WRITE)
+1 -2
View File
@@ -6,7 +6,6 @@ import inspect
from plyngent.agent import ToolRegistry, ToolTag, tool from plyngent.agent import ToolRegistry, ToolTag, tool
from plyngent.tools import default_tool_definitions, register_builtin_tools from plyngent.tools import default_tool_definitions, register_builtin_tools
from plyngent.tools.access import ACCESS_TOOLS
from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source
from plyngent.tools.chat import CHAT_TOOLS from plyngent.tools.chat import CHAT_TOOLS
from plyngent.tools.file import FILE_TOOLS from plyngent.tools.file import FILE_TOOLS
@@ -19,7 +18,7 @@ from plyngent.tools.vcs import VCS_TOOLS
def test_default_tool_names_match_group_lists() -> None: def test_default_tool_names_match_group_lists() -> None:
register_builtin_tools() register_builtin_tools()
selected = default_tool_definitions(surface="local") selected = default_tool_definitions(surface="local")
groups = [*ACCESS_TOOLS, *FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS, *NET_TOOLS] groups = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS, *NET_TOOLS]
assert sorted(t.name for t in selected) == sorted(t.name for t in groups) assert sorted(t.name for t in selected) == sorted(t.name for t in groups)
assert len(selected) == len(groups) assert len(selected) == len(groups)
-88
View File
@@ -3,18 +3,13 @@ from __future__ import annotations
import pytest import pytest
from plyngent.tools import ( from plyngent.tools import (
AccessMode,
WorkspaceError, WorkspaceError,
active_workspace_policy,
check_command_allowed, check_command_allowed,
get_workspace_root, get_workspace_root,
mode_covers,
parse_access_mode,
resolve_path, resolve_path,
set_command_denylist, set_command_denylist,
set_path_denylist, set_path_denylist,
) )
from plyngent.tools.context import SessionState, bind_session
def test_resolve_relative_and_absolute(workspace: object) -> None: def test_resolve_relative_and_absolute(workspace: object) -> None:
@@ -32,89 +27,6 @@ def test_escape_rejected(workspace: object) -> None:
_ = resolve_path("../outside") _ = resolve_path("../outside")
def test_escape_error_hints_request_tool(workspace: object) -> None:
del workspace
with pytest.raises(WorkspaceError, match="request_directory_access"):
_ = resolve_path("../outside")
def test_access_mode_parse_and_covers() -> None:
assert parse_access_mode(" Read ") is AccessMode.READ
assert parse_access_mode("EXEC") is AccessMode.EXEC
assert parse_access_mode("bogus") is None
assert mode_covers(AccessMode.EXEC, AccessMode.WRITE)
assert mode_covers(AccessMode.WRITE, AccessMode.WRITE)
assert not mode_covers(AccessMode.READ, AccessMode.WRITE)
def test_config_grant_modes(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
del workspace
outside = tmp_path_factory.mktemp("granted")
target = outside / "data.txt"
_ = target.write_text("x", encoding="utf-8")
policy = active_workspace_policy()
policy.config_allow[outside] = AccessMode.READ
try:
assert resolve_path(str(target), required=AccessMode.READ) == target
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(target), required=AccessMode.WRITE)
policy.config_allow[outside] = AccessMode.WRITE
assert resolve_path(str(target), required=AccessMode.WRITE) == target
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(target), required=AccessMode.EXEC)
finally:
policy.config_allow.clear()
def test_session_grant_covers_subtree(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
del workspace
outside = tmp_path_factory.mktemp("session")
nested = outside / "a" / "b"
nested.mkdir(parents=True)
with bind_session(SessionState(access_grants={outside: AccessMode.EXEC})):
assert resolve_path(str(nested), required=AccessMode.EXEC) == nested
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(nested), required=AccessMode.READ)
def test_file_grant_is_exact_path(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
del workspace
outside = tmp_path_factory.mktemp("file-grant")
granted = outside / "one.txt"
sibling = outside / "two.txt"
_ = granted.write_text("1", encoding="utf-8")
_ = sibling.write_text("2", encoding="utf-8")
policy = active_workspace_policy()
policy.config_allow[granted] = AccessMode.READ
try:
assert resolve_path(str(granted), required=AccessMode.READ) == granted
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(sibling), required=AccessMode.READ)
# A file grant does not cover its parent directory.
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(outside), required=AccessMode.READ)
finally:
policy.config_allow.clear()
def test_denylist_wins_over_grant(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
del workspace
outside = tmp_path_factory.mktemp("denied")
secret = outside / "secret"
secret.mkdir()
key = secret / "key"
_ = key.write_text("k", encoding="utf-8")
policy = active_workspace_policy()
policy.config_allow[outside] = AccessMode.WRITE
set_path_denylist(["/secret/"])
try:
with pytest.raises(WorkspaceError, match="matched '/secret/'"):
_ = resolve_path(str(key), required=AccessMode.READ)
finally:
set_path_denylist(None)
policy.config_allow.clear()
def test_path_denylist(workspace: object) -> None: def test_path_denylist(workspace: object) -> None:
from pathlib import Path from pathlib import Path