mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
Compare commits
11 Commits
66c58cf156
..
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| ef8d74a92a | |||
| 3fa05a86c6 | |||
| 18cbcf6835 | |||
| aba3765def | |||
| 685370d3cb | |||
| af1e4a4770 | |||
| a1f106a5e2 | |||
| 22f52a0bd2 | |||
| d17c80b07e | |||
| 49d1e08ebe | |||
| ba021cb138 |
@@ -66,7 +66,7 @@ MCP (`runtime/mcp_client.py`): `McpManager` owns one `McpServerConnection` per `
|
||||
|
||||
### Memory (`memory/`)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Agent (`agent/`)
|
||||
|
||||
@@ -80,24 +80,25 @@ 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.
|
||||
- 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`.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### Tools (`tools/`)
|
||||
|
||||
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
|
||||
- **`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.
|
||||
- **`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`.
|
||||
- **`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=...`).
|
||||
- **`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.
|
||||
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget.
|
||||
- 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.
|
||||
- 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.
|
||||
- **`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`).
|
||||
- **`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`.
|
||||
- **`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.
|
||||
- **`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.
|
||||
|
||||
### Prompting (`prompting.py`)
|
||||
|
||||
@@ -107,8 +108,8 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
|
||||
|
||||
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), `--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.
|
||||
- **`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`).
|
||||
- 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.
|
||||
- `/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**.
|
||||
- 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`.
|
||||
|
||||
@@ -150,6 +150,10 @@ max_context_tokens = 200000
|
||||
# Fold MCP server initialize ``instructions`` (usage guidance) into the
|
||||
# system prompt when tools are on (default true; false ignores server text).
|
||||
# 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.
|
||||
# [plugins]
|
||||
@@ -214,10 +218,13 @@ plyngent chat --session 3
|
||||
| `--stream` / `--no-stream` | Streaming deltas (default on) |
|
||||
| `--quiet` | Less status on stderr |
|
||||
| `--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`, … |
|
||||
|
||||
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)
|
||||
|
||||
```bash
|
||||
@@ -263,6 +270,7 @@ Type `/help` in the REPL for the live list. Common ones:
|
||||
| `/models` | List config + remote `GET /models` (always re-fetches) |
|
||||
| `/models --persist` | Merge remote catalog into TOML for this provider |
|
||||
| `/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 |
|
||||
| `/quit` | Leave the REPL |
|
||||
|
||||
@@ -272,15 +280,16 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
|
||||
|
||||
- **Workspace** = root for file/process/VCS tools (default cwd).
|
||||
- **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.
|
||||
|
||||
## 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), `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), `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`).
|
||||
|
||||
Safety defaults:
|
||||
|
||||
- Paths stay under the workspace; optional `path_denylist` substrings (`tree` also skips denylisted children by default).
|
||||
- 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).
|
||||
- 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).
|
||||
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
|
||||
|
||||
+2
-2
@@ -27,8 +27,8 @@
|
||||
- agent: Kind-based tool loop, streaming, usage, compact; bridges:
|
||||
``responses_bridge``/``responses_dispatch``, ``messages_bridge``/``messages_dispatch``;
|
||||
``@tool`` / tags / registry. History stays chat-completions-shaped.
|
||||
- tools: Workspace file/process/VCS/chat/todo/net tools; catalog; plugins;
|
||||
instance/session context and views.
|
||||
- tools: Workspace file/process/VCS/chat/todo/net tools + directory-access
|
||||
grants (`access`); catalog; plugins; instance/session context and views.
|
||||
- prompting: Shared ask/choose/form for CLI and tools.
|
||||
- cli: Click entry, slash registry, REPL, one-shot chat.
|
||||
- web: Web service (Phase H; not implemented).
|
||||
|
||||
@@ -58,6 +58,13 @@ max_tool_result_chars = 32000
|
||||
parallel_tools = true
|
||||
confirm_destructive = true
|
||||
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).
|
||||
max_context_tokens = 200000
|
||||
# Fold connected MCP servers' initialize ``instructions`` (usage guidance)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plyngent"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
description = "LLM chat and agent toolkit"
|
||||
authors = [
|
||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||
|
||||
+31
-4
@@ -124,9 +124,14 @@ def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None:
|
||||
return text or None
|
||||
|
||||
|
||||
def _setup_hooks(*, interactive: bool) -> None:
|
||||
"""Install interactive limit hooks (workspace policy is set on ReplState)."""
|
||||
if interactive:
|
||||
def _setup_hooks(*, interactive: bool, auto_continue: bool) -> None:
|
||||
"""Install limit hooks (workspace policy is set on ReplState).
|
||||
|
||||
``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()
|
||||
else:
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
@@ -221,6 +226,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
prompt_text: str | None,
|
||||
stream: bool,
|
||||
yes: bool,
|
||||
auto_continue: bool,
|
||||
quiet: bool,
|
||||
) -> int:
|
||||
oneshot = prompt_text is not None
|
||||
@@ -236,7 +242,8 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
if store.recoverable_providers:
|
||||
_warn_recoverable_providers(store.recoverable_providers)
|
||||
|
||||
_setup_hooks(interactive=interactive)
|
||||
auto_effective = auto_continue or store.agent_config.auto_continue_limits
|
||||
_setup_hooks(interactive=interactive, auto_continue=auto_effective)
|
||||
# --yes forces sticky YOLO; else derive from config.confirm_destructive.
|
||||
from plyngent.cli.state import YoloMode
|
||||
|
||||
@@ -315,6 +322,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
max_rounds=max_rounds,
|
||||
stream_enabled=stream,
|
||||
interactive_limits=interactive,
|
||||
auto_continue_limits=auto_effective,
|
||||
yolo=yolo,
|
||||
mcp_manager=mcp_manager,
|
||||
)
|
||||
@@ -333,6 +341,10 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
prompt_policy_fetch_confirm,
|
||||
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.
|
||||
if remote_ids is not None:
|
||||
state.seed_remote_models(remote_ids)
|
||||
@@ -373,6 +385,13 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
set_fetch_policy_confirm_hook(None, instance=state_obj.instance_state)
|
||||
clear_private_grants(instance=state_obj.instance_state)
|
||||
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:
|
||||
await state_obj.mcp_manager.aclose()
|
||||
await state_obj.instance_state.shutdown()
|
||||
@@ -469,6 +488,12 @@ def main(ctx: click.Context, log_level: str) -> None:
|
||||
default=False,
|
||||
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(
|
||||
"--quiet",
|
||||
is_flag=True,
|
||||
@@ -487,6 +512,7 @@ def chat_cmd(
|
||||
prompt: str | None,
|
||||
stream: bool, # noqa: FBT001
|
||||
yes: bool, # noqa: FBT001
|
||||
auto_continue: bool, # noqa: FBT001
|
||||
quiet: bool, # noqa: FBT001
|
||||
) -> None:
|
||||
"""Interactive chat REPL, or one-shot with ``-p`` / stdin.
|
||||
@@ -527,6 +553,7 @@ def chat_cmd(
|
||||
prompt_text=prompt_text,
|
||||
stream=stream,
|
||||
yes=yes,
|
||||
auto_continue=auto_continue,
|
||||
quiet=quiet,
|
||||
)
|
||||
)
|
||||
|
||||
+124
-8
@@ -4,6 +4,7 @@ import contextlib
|
||||
import selectors
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
|
||||
@@ -18,16 +19,56 @@ from plyngent.prompting import (
|
||||
confirm,
|
||||
get_prompt_backend,
|
||||
)
|
||||
from plyngent.tools.access import AccessDecision
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
from plyngent.tools.workspace import AccessMode, parse_access_mode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import Path
|
||||
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
|
||||
|
||||
_BOX_MIN_WIDTH = 40
|
||||
_BOX_MAX_WIDTH = 100
|
||||
_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:
|
||||
try:
|
||||
@@ -104,10 +145,33 @@ def _echo_continue_limit(reason: str) -> None:
|
||||
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:
|
||||
try:
|
||||
_echo_continue_limit(reason)
|
||||
return confirm("Raise limit and continue?", default=True)
|
||||
return _decide_continue_limit(reason)
|
||||
except NonInteractiveError:
|
||||
return False
|
||||
|
||||
@@ -119,15 +183,11 @@ def prompt_continue_limit(reason: str) -> bool:
|
||||
|
||||
|
||||
async def prompt_continue_limit_async(reason: str) -> bool:
|
||||
"""Async variant: confirm off the event loop so the turn is not cancelled."""
|
||||
"""Async variant: prompt off the event loop so the turn is not cancelled."""
|
||||
try:
|
||||
from plyngent.prompting import run_prompt_async
|
||||
|
||||
def _run() -> bool:
|
||||
_echo_continue_limit(reason)
|
||||
return confirm("Raise limit and continue?", default=True)
|
||||
|
||||
return await run_prompt_async(_run)
|
||||
return await run_prompt_async(lambda: _decide_continue_limit(reason))
|
||||
except NonInteractiveError:
|
||||
return False
|
||||
|
||||
@@ -287,6 +347,62 @@ def prompt_policy_fetch_confirm(
|
||||
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 confirm: True allow, False deny, str = deny with user comment."""
|
||||
del args
|
||||
|
||||
@@ -36,14 +36,17 @@ def _echo_interrupted() -> None:
|
||||
async def run_repl(state: ReplState) -> None:
|
||||
"""Interactive chat loop with readline editing, history, and Tab completion."""
|
||||
setup_readline(state)
|
||||
from plyngent.cli.limits import auto_continue_enabled
|
||||
|
||||
yolo = state.effective_yolo()
|
||||
yolo_part = f" yolo={yolo}" if yolo != "off" else ""
|
||||
auto_part = " limit_auto=on" if auto_continue_enabled() else ""
|
||||
click.echo(
|
||||
f"plyngent chat provider={state.provider_name} model={state.model} "
|
||||
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
|
||||
f"rounds={state.max_rounds} messages={len(state.agent.messages)} "
|
||||
f"stream={'on' if state.agent.stream else 'off'} "
|
||||
f"verbose={'on' if state.verbose else 'off'}{yolo_part}"
|
||||
f"verbose={'on' if state.verbose else 'off'}{yolo_part}{auto_part}"
|
||||
)
|
||||
click.echo('Type /help for commands. Multiline: """ … """. Empty line is ignored.')
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import click
|
||||
|
||||
from plyngent.cli.display import render_events
|
||||
from plyngent.cli.interrupt import allow_task_cancel, set_sigint_reinstall
|
||||
from plyngent.cli.limits import reset_auto_continue_turn
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
@@ -184,7 +185,23 @@ async def run_turn_with_retries(
|
||||
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``.
|
||||
|
||||
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)
|
||||
attempt = 0
|
||||
current: Callable[[], AsyncIterator[AgentEvent]] = starter
|
||||
|
||||
@@ -521,6 +521,7 @@ def config_cmd(state: ReplState) -> None:
|
||||
def status_cmd(state: ReplState) -> None:
|
||||
"""Show session/provider/tools/rounds status."""
|
||||
from plyngent.agent.budget import estimate_messages_chars
|
||||
from plyngent.tools.access import access_grant_rows
|
||||
|
||||
pending = state.agent.pending_retry_text
|
||||
pending_disp = "yes" if pending else "no"
|
||||
@@ -555,6 +556,7 @@ def status_cmd(state: ReplState) -> None:
|
||||
f"usage_last_turn={last_u.format_line(billed=True)} "
|
||||
f"rounds={last_rounds}\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}"
|
||||
)
|
||||
|
||||
@@ -1016,11 +1018,17 @@ def btw_cmd(
|
||||
else:
|
||||
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:
|
||||
_await(_run())
|
||||
except Exception as exc: # noqa: BLE001 — surface side-turn failures
|
||||
click.secho(f"error: btw failed: {exc}", fg="red")
|
||||
return
|
||||
finally:
|
||||
reset_auto_continue_turn()
|
||||
click.secho("(btw done — main session unchanged)", fg="bright_black")
|
||||
|
||||
|
||||
@@ -1205,6 +1213,70 @@ def mcp_slash_cmd(state: ReplState, action: str | None) -> None:
|
||||
_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")
|
||||
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
|
||||
@click.pass_obj
|
||||
|
||||
+104
-4
@@ -26,17 +26,31 @@ from plyngent.tools import InstanceState, SessionState
|
||||
from plyngent.tools.view import MemoryViewStore, session_data_view
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from plyngent.config.models import Provider
|
||||
from plyngent.config.store import ConfigStore
|
||||
from plyngent.memory import MemoryStore
|
||||
from plyngent.memory.database.schema import Session as SessionRow
|
||||
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"]
|
||||
|
||||
|
||||
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
|
||||
class ReplState:
|
||||
"""Mutable REPL session state."""
|
||||
@@ -55,6 +69,8 @@ class ReplState:
|
||||
markdown_enabled: bool = True
|
||||
# One-shot / scripts: never prompt to raise tool-loop limits.
|
||||
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.
|
||||
# off = confirm; on = skip (sticky); once = skip next user turn then off.
|
||||
yolo: YoloMode | None = None
|
||||
@@ -82,6 +98,10 @@ class ReplState:
|
||||
self.workspace = Path(self.workspace).expanduser().resolve()
|
||||
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.agent = self._make_agent()
|
||||
self.sync_display_flags()
|
||||
@@ -93,6 +113,12 @@ class ReplState:
|
||||
set_verbose_tool_results(self.verbose)
|
||||
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:
|
||||
key = normalize_workspace(self.workspace)
|
||||
if key is None:
|
||||
@@ -148,11 +174,17 @@ class ReplState:
|
||||
self._todo_persist_tasks.add(task)
|
||||
task.add_done_callback(self._todo_persist_tasks.discard)
|
||||
|
||||
def _session_data_for_todo(self, *, grants: dict[str, bool] | None = None) -> SessionState:
|
||||
def _session_data_for_todo(
|
||||
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).
|
||||
|
||||
*grants* defaults to empty (new session / startup). Pass the previous map
|
||||
only when intentionally preserving soft-confirm trust across rebinds.
|
||||
*access_grants* seeds session directory grants (empty clears them).
|
||||
"""
|
||||
grant_map = dict(grants or {})
|
||||
store = MemoryViewStore({"todo": self.todo_stack.to_raw(), "grants": dict(grant_map)})
|
||||
@@ -161,7 +193,9 @@ class ReplState:
|
||||
data=session_data_view(store=store),
|
||||
todo=self.todo_stack,
|
||||
on_todo_change=self._todo_on_change,
|
||||
on_access_change=self._on_access_change,
|
||||
grants=grant_map,
|
||||
access_grants=dict(access_grants or {}),
|
||||
)
|
||||
|
||||
def _bind_todo_tools(self) -> None:
|
||||
@@ -203,6 +237,67 @@ class ReplState:
|
||||
if hasattr(self, "agent"):
|
||||
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:
|
||||
if not self.tools_enabled:
|
||||
return None
|
||||
@@ -250,7 +345,7 @@ class ReplState:
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def _make_agent(self) -> ChatAgent:
|
||||
from plyngent.cli.limits import prompt_continue_limit_async
|
||||
from plyngent.cli.limits import auto_continue_enabled, prompt_continue_limit_async
|
||||
from plyngent.config import compose_agent_system_content
|
||||
|
||||
agent_cfg = self.config.agent_config
|
||||
@@ -260,7 +355,7 @@ class ReplState:
|
||||
agent_cfg.tool_directives,
|
||||
mcp_text,
|
||||
)
|
||||
on_limit = prompt_continue_limit_async if self.interactive_limits else None
|
||||
on_limit = prompt_continue_limit_async if (self.interactive_limits or auto_continue_enabled()) else None
|
||||
peak = 0
|
||||
band = 0
|
||||
last_req = None
|
||||
@@ -574,6 +669,7 @@ class ReplState:
|
||||
return False
|
||||
|
||||
async def new_session(self, name: str = "chat") -> None:
|
||||
self._reset_limit_auto_continue()
|
||||
session = await self.memory.create_session(
|
||||
name=name,
|
||||
workspace=self.workspace,
|
||||
@@ -612,6 +708,7 @@ class ReplState:
|
||||
"""Load a session; on workspace mismatch, prompt keep / rebind / abort."""
|
||||
from plyngent.cli.limits import prompt_workspace_mismatch
|
||||
|
||||
self._reset_limit_auto_continue()
|
||||
row = await self.memory.get_session(session_id)
|
||||
if row is None:
|
||||
msg = f"session not found: {session_id}"
|
||||
@@ -645,9 +742,11 @@ class ReplState:
|
||||
self.agent = self._make_agent()
|
||||
await self.agent.load_history()
|
||||
await self.load_todo_stack()
|
||||
await self.load_access_grants()
|
||||
|
||||
async def resume_latest_or_new(self, name: str = "chat") -> str:
|
||||
"""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)
|
||||
if latest is None:
|
||||
await self.new_session(name=name)
|
||||
@@ -660,6 +759,7 @@ class ReplState:
|
||||
self.agent = self._make_agent()
|
||||
await self.agent.load_history()
|
||||
await self.load_todo_stack()
|
||||
await self.load_access_grants()
|
||||
_ = await self.memory.touch_session(latest.sid)
|
||||
return "resume"
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ from msgspec import Struct, field
|
||||
# ``https://api.deepseek.com/anthropic``)
|
||||
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.
|
||||
# Set ``system_prompt = ""`` to omit the persona block only.
|
||||
# Override with a multi-line TOML literal (prefer ''' so nested " is fine).
|
||||
@@ -23,6 +26,8 @@ DEFAULT_TOOL_DIRECTIVES = """\
|
||||
### Workspace
|
||||
- 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.
|
||||
- Need a path outside the workspace? Call `request_directory_access` \
|
||||
(human approves; denylists apply) instead of retrying an escaped path.
|
||||
|
||||
### Files
|
||||
- Prefer file tools over shell. `edit_replace` fails usually means a bad match — \
|
||||
@@ -109,6 +114,12 @@ class AgentConfig(Struct, omit_defaults=True):
|
||||
parallel_tools: bool = True
|
||||
confirm_destructive: bool = True
|
||||
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
|
||||
|
||||
# How to inject todo stack nags into model context (see agent/todo_nag.py).
|
||||
|
||||
@@ -34,6 +34,8 @@ class Session(PlyngentBase):
|
||||
model: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
# Todo/task stack JSON for multi-step sub-tasks (optional).
|
||||
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_prompt_tokens: Mapped[int | None] = mapped_column(nullable=True)
|
||||
peak_prompt_tokens: Mapped[int | None] = mapped_column(nullable=True)
|
||||
|
||||
@@ -241,6 +241,31 @@ class MemoryStore:
|
||||
await session.refresh(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(
|
||||
self,
|
||||
sid: int,
|
||||
@@ -400,18 +425,30 @@ def _migrate_session_v3(sync_conn: object, columns: set[str]) -> None:
|
||||
_ = 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
|
||||
# _SCHEMA_MIGRATIONS) when a new release adds or changes columns.
|
||||
_SCHEMA_VERSION = 3
|
||||
_SCHEMA_VERSION = 4
|
||||
|
||||
# Ordered (version, step) pairs. Each step receives the current ``session``
|
||||
# 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.
|
||||
# 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], ...] = (
|
||||
(1, _migrate_session_v1),
|
||||
(2, _migrate_session_v2),
|
||||
(3, _migrate_session_v3),
|
||||
(4, _migrate_session_v4),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
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 ToolSource as ToolSource
|
||||
from .catalog import catalog_scope as catalog_scope
|
||||
@@ -66,6 +80,7 @@ from .workspace import (
|
||||
DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST,
|
||||
)
|
||||
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 WorkspacePolicy as WorkspacePolicy
|
||||
from .workspace import active_workspace_policy as active_workspace_policy
|
||||
@@ -74,12 +89,16 @@ 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_workspace_allowlist as clear_workspace_allowlist
|
||||
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_path_denylist as get_path_denylist
|
||||
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 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 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 remove_workspace_allowlist as remove_workspace_allowlist
|
||||
from .workspace import resolve_path as resolve_path
|
||||
@@ -88,3 +107,4 @@ 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_timeout as set_policy_confirm_timeout
|
||||
from .workspace import set_workspace_root as set_workspace_root
|
||||
from .workspace import within_workspace_roots as within_workspace_roots
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""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,
|
||||
]
|
||||
@@ -218,6 +218,7 @@ def _ensure_builtin_definitions_registered(catalog: ToolCatalog) -> None:
|
||||
import time (usually the process catalog). Test ``catalog_scope(empty=True)``
|
||||
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.file import FILE_TOOLS
|
||||
from plyngent.tools.net import NET_TOOLS
|
||||
@@ -226,6 +227,7 @@ def _ensure_builtin_definitions_registered(catalog: ToolCatalog) -> None:
|
||||
from plyngent.tools.vcs import VCS_TOOLS
|
||||
|
||||
for definition in (
|
||||
*ACCESS_TOOLS,
|
||||
*FILE_TOOLS,
|
||||
*PROCESS_TOOLS,
|
||||
*VCS_TOOLS,
|
||||
|
||||
@@ -14,7 +14,7 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.agent.todo_stack import TodoStack
|
||||
from plyngent.tools.workspace import WorkspacePolicy
|
||||
from plyngent.tools.workspace import AccessMode, WorkspacePolicy
|
||||
|
||||
|
||||
def _default_instance_data() -> PersistentDataView[Any]:
|
||||
@@ -74,10 +74,15 @@ class SessionState:
|
||||
todo: TodoStack | None = None
|
||||
# Host hook after todo tools mutate (e.g. CLI memory persist).
|
||||
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).
|
||||
# Durable copy lives under data["grants"] (see plyngent.tools.grants).
|
||||
grants: dict[str, bool] = 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:
|
||||
return bool(self.grants.get(tool_name))
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import shlex
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from plyngent.tools.workspace import WorkspaceError, resolve_path
|
||||
from plyngent.tools.workspace import AccessMode, WorkspaceError, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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:
|
||||
return None
|
||||
try:
|
||||
target = resolve_path(path)
|
||||
target = resolve_path(path, required=AccessMode.WRITE)
|
||||
except WorkspaceError:
|
||||
# Path policy will fail later; no soft-confirm without a resolved target.
|
||||
return None
|
||||
@@ -160,7 +160,7 @@ def _copy_path_reason(args: Mapping[str, object]) -> str | None:
|
||||
if not isinstance(dst, str) or not dst:
|
||||
return f"copy {src!r} → {dst!r} (overwrite)"
|
||||
try:
|
||||
target = resolve_path(dst)
|
||||
target = resolve_path(dst, required=AccessMode.WRITE)
|
||||
except WorkspaceError:
|
||||
return None
|
||||
if target.exists():
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolTag, lineno_read_lines, lineno_read_mtime, tool
|
||||
from plyngent.tools.workspace import resolve_path
|
||||
from plyngent.tools.workspace import AccessMode, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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.
|
||||
"""
|
||||
target = resolve_path(path)
|
||||
target = resolve_path(path, required=AccessMode.WRITE)
|
||||
if not target.is_file():
|
||||
return f"error: not a file: {path}"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.workspace import resolve_path
|
||||
from plyngent.tools.workspace import AccessMode, resolve_path
|
||||
|
||||
|
||||
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"
|
||||
if max_replaces < 1:
|
||||
return "error: max_replaces must be >= 1"
|
||||
target = resolve_path(path)
|
||||
target = resolve_path(path, required=AccessMode.WRITE)
|
||||
if not target.is_file():
|
||||
return f"error: not a file: {path}"
|
||||
text = target.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
@@ -4,7 +4,7 @@ import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.agent import ToolTag, invalidate_lineno_read, tool
|
||||
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
|
||||
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_workspace_root, resolve_path
|
||||
|
||||
|
||||
def _kind(path: Path) -> str:
|
||||
@@ -26,18 +26,28 @@ def _remove_existing(path: Path) -> None:
|
||||
|
||||
def _resolve_pair(src: str, dst: str) -> tuple[Path, Path, Path] | str:
|
||||
try:
|
||||
source = resolve_path(src)
|
||||
dest = resolve_path(dst)
|
||||
source = resolve_path(src, required=AccessMode.READ)
|
||||
dest = resolve_path(dst, required=AccessMode.WRITE)
|
||||
root = get_workspace_root()
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
return source, dest, root
|
||||
|
||||
|
||||
def _prepare_dest(source: Path, dest: Path, root: Path, *, overwrite: bool, dst_label: str) -> Path | str:
|
||||
def _display(path: Path, root: 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."""
|
||||
if source.is_file() and dest.is_dir():
|
||||
dest = resolve_path(str((dest / source.name).relative_to(root)))
|
||||
# ``dest`` may be outside the primary root (temp workspace / grant), so
|
||||
# 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 not overwrite:
|
||||
return f"error: destination exists: {dst_label} (set overwrite=true)"
|
||||
@@ -56,7 +66,7 @@ def _copy_or_move_validated(
|
||||
overwrite: bool,
|
||||
action: str,
|
||||
) -> str:
|
||||
prepared = _prepare_dest(source, dest, root, overwrite=overwrite, dst_label=dst)
|
||||
prepared = _prepare_dest(source, dest, overwrite=overwrite, dst_label=dst)
|
||||
if isinstance(prepared, str):
|
||||
return prepared
|
||||
dest = prepared
|
||||
@@ -72,12 +82,12 @@ def _copy_or_move_validated(
|
||||
# new. copy2/copytree preserve the source mtime, so the freshness
|
||||
# check alone could miss the change — invalidate explicitly.
|
||||
invalidate_lineno_read(str(dest))
|
||||
return f"copied {label} {src} -> {dest.relative_to(root)}"
|
||||
return f"copied {label} {src} -> {_display(dest, root)}"
|
||||
moved = Path(shutil.move(str(source), str(dest))).resolve()
|
||||
# shutil.move preserves mtime too; drop read state for both paths.
|
||||
invalidate_lineno_read(str(source))
|
||||
invalidate_lineno_read(str(moved))
|
||||
return f"moved {_kind(moved)} {src} -> {moved.relative_to(root)}"
|
||||
return f"moved {_kind(moved)} {src} -> {_display(moved, root)}"
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
except OSError as exc:
|
||||
@@ -147,7 +157,7 @@ async def delete_path(path: str, *, recursive: bool = False) -> str:
|
||||
``recursive=true`` (uses ``shutil.rmtree``). The workspace root cannot be deleted.
|
||||
"""
|
||||
try:
|
||||
target = resolve_path(path)
|
||||
target = resolve_path(path, required=AccessMode.WRITE)
|
||||
root = get_workspace_root()
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.file.tree import VCS_DIR_NAMES
|
||||
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
|
||||
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_workspace_root, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@@ -57,7 +57,7 @@ def _collect_glob(
|
||||
def _resolve_glob_base(path: str) -> tuple[Path, Path] | str:
|
||||
try:
|
||||
root = get_workspace_root()
|
||||
base = resolve_path(path)
|
||||
base = resolve_path(path, required=AccessMode.READ)
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
if not base.is_dir():
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.file.tree import VCS_DIR_NAMES
|
||||
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
|
||||
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_workspace_root, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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:
|
||||
try:
|
||||
base = resolve_path(path)
|
||||
base = resolve_path(path, required=AccessMode.READ)
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
if not base.exists():
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.workspace import resolve_path
|
||||
from plyngent.tools.workspace import AccessMode, resolve_path
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||
async def listdir(path: str = ".") -> str:
|
||||
"""List entries in a directory under the workspace (name and type)."""
|
||||
target = resolve_path(path)
|
||||
target = resolve_path(path, required=AccessMode.READ)
|
||||
if not target.is_dir():
|
||||
return f"error: not a directory: {path}"
|
||||
lines: list[str] = []
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import cast
|
||||
from plyngent.agent import ToolTag, mark_lineno_read, tool
|
||||
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.workspace import resolve_path
|
||||
from plyngent.tools.workspace import AccessMode, resolve_path
|
||||
|
||||
_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).
|
||||
Missing paths and directories are distinguished for the caller.
|
||||
"""
|
||||
target = resolve_path(path)
|
||||
target = resolve_path(path, required=AccessMode.READ)
|
||||
if not target.exists():
|
||||
return None, f"error: file not found: {path}"
|
||||
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.
|
||||
Pass ``max_chars < 1`` to read unbounded.
|
||||
"""
|
||||
target = resolve_path(path)
|
||||
target = resolve_path(path, required=AccessMode.READ)
|
||||
text, err = read_raw_text(path)
|
||||
if err:
|
||||
return err
|
||||
|
||||
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
|
||||
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_path_denylist, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
@@ -270,7 +270,7 @@ async def tree(
|
||||
return "error: max_entries must be >= 1"
|
||||
|
||||
try:
|
||||
origin = resolve_path(path)
|
||||
origin = resolve_path(path, required=AccessMode.READ)
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
if not origin.is_dir():
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.workspace import resolve_path
|
||||
from plyngent.tools.workspace import AccessMode, resolve_path
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
||||
async def write_file(path: str, content: str) -> str:
|
||||
"""Write text content to a file under the workspace (creates parents)."""
|
||||
target = resolve_path(path)
|
||||
target = resolve_path(path, required=AccessMode.WRITE)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
_ = target.write_text(content, encoding="utf-8")
|
||||
# The write changes the file's mtime, so edit_lineno's freshness check
|
||||
|
||||
@@ -10,6 +10,7 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.tools.workspace import (
|
||||
AccessMode,
|
||||
WorkspaceError,
|
||||
check_command_allowed,
|
||||
get_workspace_root,
|
||||
@@ -61,7 +62,7 @@ def format_command_result(
|
||||
|
||||
def resolve_workdir(cwd: str) -> tuple["Path", str]:
|
||||
"""Return (absolute workdir, display path relative to workspace when possible)."""
|
||||
workdir = resolve_path(cwd)
|
||||
workdir = resolve_path(cwd, required=AccessMode.EXEC)
|
||||
if not workdir.is_dir():
|
||||
msg = f"not a directory: {cwd}"
|
||||
raise WorkspaceError(msg)
|
||||
|
||||
@@ -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_terminal import sanitize_pty_output_for_tool
|
||||
from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
|
||||
from plyngent.tools.workspace import AccessMode, WorkspaceError, check_command_allowed, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -122,7 +122,7 @@ class PtyManager:
|
||||
msg = "PTY is not available on this platform (Windows needs pywinpty)"
|
||||
raise WorkspaceError(msg)
|
||||
check_command_allowed(command)
|
||||
workdir = resolve_path(cwd)
|
||||
workdir = resolve_path(cwd, required=AccessMode.EXEC)
|
||||
if not workdir.is_dir():
|
||||
msg = f"cwd is not a directory: {cwd}"
|
||||
raise WorkspaceError(msg)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Sequence
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
|
||||
from plyngent.tools.context import InstanceState
|
||||
from plyngent.tools.context import InstanceState, SessionState
|
||||
|
||||
DEFAULT_COMMAND_DENYLIST: frozenset[str] = frozenset(
|
||||
{
|
||||
@@ -39,6 +40,36 @@ MAX_TEMPORARY_WORKSPACES = 16
|
||||
# Timed human override for command denylist (independent of YOLO soft-confirm).
|
||||
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.
|
||||
type PolicyConfirmHook = Callable[[str, Sequence[str], float], bool]
|
||||
|
||||
@@ -63,6 +94,10 @@ class WorkspacePolicy:
|
||||
policy_allowed_commands: set[str] = field(default_factory=set)
|
||||
policy_confirm_hook: PolicyConfirmHook | None = None
|
||||
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:
|
||||
@@ -71,6 +106,12 @@ def _bound_instance() -> InstanceState | None:
|
||||
return get_instance()
|
||||
|
||||
|
||||
def _bound_session() -> SessionState | None:
|
||||
from plyngent.tools.context import get_session
|
||||
|
||||
return get_session()
|
||||
|
||||
|
||||
def require_bound_instance() -> InstanceState:
|
||||
"""Return the bound instance or raise :class:`WorkspaceError`."""
|
||||
instance = _bound_instance()
|
||||
@@ -243,11 +284,55 @@ def _under_any_root(resolved: Path, instance: InstanceState, policy: WorkspacePo
|
||||
return False
|
||||
|
||||
|
||||
def resolve_path(path: str | Path) -> Path:
|
||||
"""Resolve ``path`` under the workspace root or an allowlisted temp root.
|
||||
def granted_access_mode(resolved: Path) -> AccessMode | None:
|
||||
"""Highest config / session / process mode covering *resolved*, if any."""
|
||||
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
|
||||
paths may also land under a temporary workspace allowlist entry.
|
||||
paths may also land under a temporary workspace allowlist entry or a
|
||||
directory-access grant; ``required`` is the access mode the caller needs
|
||||
(default :attr:`AccessMode.WRITE`, the conservative choice).
|
||||
"""
|
||||
instance = require_bound_instance()
|
||||
policy = instance.workspace
|
||||
@@ -256,15 +341,17 @@ def resolve_path(path: str | Path) -> Path:
|
||||
if not candidate.is_absolute():
|
||||
candidate = root / candidate
|
||||
resolved = candidate.expanduser().resolve()
|
||||
if not _under_any_root(resolved, instance, policy):
|
||||
msg = f"path escapes workspace root ({root}): {path}"
|
||||
if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required):
|
||||
msg = (
|
||||
f"path escapes workspace root ({root}): {path}; "
|
||||
"call request_directory_access to request access to a directory outside the workspace"
|
||||
)
|
||||
raise WorkspaceError(msg)
|
||||
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
|
||||
resolved_str = str(resolved).replace("\\", "/")
|
||||
for pattern in policy.path_denylist:
|
||||
if pattern and pattern.replace("\\", "/") in resolved_str:
|
||||
msg = f"path denied by policy (matched {pattern!r}): {path}"
|
||||
raise WorkspaceError(msg)
|
||||
matched = denylist_match(resolved)
|
||||
if matched is not None:
|
||||
msg = f"path denied by policy (matched {matched!r}): {path}"
|
||||
raise WorkspaceError(msg)
|
||||
return resolved
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""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()
|
||||
@@ -16,6 +16,8 @@ from plyngent.cli.interrupt import (
|
||||
)
|
||||
from plyngent.cli.limits import prompt_continue_limit, prompt_continue_limit_async
|
||||
from plyngent.cli.retry import run_cancellable
|
||||
from plyngent.prompting import temporary_backend
|
||||
from tests.test_prompting import ScriptedBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
@@ -39,12 +41,16 @@ def test_nested_pause_depth() -> None:
|
||||
|
||||
|
||||
def test_prompt_continue_limit_under_pause(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def _confirm(*_a: object, **_k: object) -> bool:
|
||||
assert allow_task_cancel() is False
|
||||
return True
|
||||
backend = ScriptedBackend(["y"])
|
||||
|
||||
monkeypatch.setattr("click.confirm", _confirm)
|
||||
assert prompt_continue_limit("too many rounds") is True
|
||||
def _read_line(prompt: str, *, default: str | None = None, completions: object = None) -> str:
|
||||
del prompt, default, completions
|
||||
assert allow_task_cancel() is False
|
||||
return "y"
|
||||
|
||||
monkeypatch.setattr(backend, "read_line", _read_line)
|
||||
with temporary_backend(backend):
|
||||
assert prompt_continue_limit("too many rounds") is True
|
||||
|
||||
|
||||
async def test_run_in_prompt_thread_pauses_cancel() -> None:
|
||||
@@ -60,11 +66,9 @@ async def test_run_in_prompt_thread_pauses_cancel() -> None:
|
||||
|
||||
|
||||
async def test_prompt_continue_limit_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def _confirm(*_a: object, **_k: object) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("click.confirm", _confirm)
|
||||
assert await prompt_continue_limit_async("too many rounds") is True
|
||||
del monkeypatch
|
||||
with temporary_backend(ScriptedBackend(["y"])):
|
||||
assert await prompt_continue_limit_async("too many rounds") is True
|
||||
|
||||
|
||||
async def test_sigint_cancels_after_prompt_pause() -> None:
|
||||
|
||||
@@ -1,28 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.cli.limits import (
|
||||
auto_continue_enabled,
|
||||
format_tool_confirm_box,
|
||||
install_cli_limit_hooks,
|
||||
prompt_confirm_tool,
|
||||
prompt_continue_limit,
|
||||
reset_auto_continue,
|
||||
reset_auto_continue_turn,
|
||||
set_auto_continue_default,
|
||||
)
|
||||
from plyngent.prompting import get_prompt_backend, temporary_backend
|
||||
from plyngent.prompting import NonInteractiveBackend, get_prompt_backend, temporary_backend
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
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:
|
||||
backend = ScriptedBackend([], confirms=[True])
|
||||
backend = ScriptedBackend(["y"])
|
||||
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):
|
||||
assert prompt_continue_limit("hit a wall") is True
|
||||
|
||||
|
||||
def test_prompt_continue_limit_no() -> None:
|
||||
backend = ScriptedBackend([], confirms=[False])
|
||||
backend = ScriptedBackend(["n"])
|
||||
with temporary_backend(backend):
|
||||
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:
|
||||
backend = ScriptedBackend([], confirms=[False])
|
||||
with temporary_backend(backend):
|
||||
@@ -54,3 +132,37 @@ def test_policy_confirm_noninteractive_denies() -> None:
|
||||
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
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
|
||||
|
||||
@@ -94,6 +94,32 @@ async def test_help_and_clear(state: ReplState) -> None:
|
||||
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:
|
||||
assert await handle_slash(state, "/help compact") is True
|
||||
out = capsys.readouterr().out
|
||||
|
||||
@@ -30,6 +30,8 @@ from plyngent.memory import MemoryStore
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from plyngent.agent import AgentEvent
|
||||
|
||||
|
||||
def test_default_retry_delays_schedule() -> None:
|
||||
assert DEFAULT_MAX_AUTO_RETRIES == 10
|
||||
@@ -296,6 +298,43 @@ async def test_second_ctrl_c_during_retry_announce_stays_benign(
|
||||
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 no_wait(_seconds: float) -> bool:
|
||||
return True
|
||||
|
||||
@@ -26,6 +26,8 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
|
||||
assert store.agent_config.parallel_tools is True
|
||||
assert store.agent_config.confirm_destructive is True
|
||||
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.plugins_config.enable == []
|
||||
assert store.plugins_config.disable == []
|
||||
@@ -82,6 +84,7 @@ parallel_tools = false
|
||||
confirm_destructive = false
|
||||
path_denylist = ["/secrets/", ".ssh/"]
|
||||
max_context_tokens = 5000
|
||||
auto_continue_limits = true
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -93,6 +96,7 @@ max_context_tokens = 5000
|
||||
assert store.agent_config.confirm_destructive is False
|
||||
assert store.agent_config.path_denylist == ["/secrets/", ".ssh/"]
|
||||
assert store.agent_config.max_context_tokens == 5000
|
||||
assert store.agent_config.auto_continue_limits is True
|
||||
composed = compose_agent_system_content(
|
||||
store.agent_config.system_prompt,
|
||||
store.agent_config.tool_directives,
|
||||
@@ -162,6 +166,33 @@ 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:
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text(
|
||||
|
||||
@@ -34,6 +34,21 @@ async def test_create_session_uses_default_user(store: MemoryStore) -> None:
|
||||
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:
|
||||
session = await store.create_session(name="usage")
|
||||
row = await store.update_session_context_usage(
|
||||
@@ -293,9 +308,9 @@ async def test_schema_migrations_stamp_version(tmp_path: object) -> None:
|
||||
db_path = tmp_path / "fresh.db"
|
||||
store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
|
||||
await store.close()
|
||||
assert await _user_version(db_path) == 3
|
||||
assert await _user_version(db_path) == 4
|
||||
columns = await _session_columns(db_path)
|
||||
assert {"workspace", "provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
|
||||
assert {"workspace", "provider_name", "model", "todo_stack", "access_grants", "last_prompt_tokens"} <= columns
|
||||
|
||||
|
||||
async def test_legacy_db_migrated_to_latest(tmp_path: object) -> None:
|
||||
@@ -311,9 +326,9 @@ async def test_legacy_db_migrated_to_latest(tmp_path: object) -> None:
|
||||
session = await store.create_session(name="migrated")
|
||||
await store.close()
|
||||
|
||||
assert await _user_version(db_path) == 3
|
||||
assert await _user_version(db_path) == 4
|
||||
columns = await _session_columns(db_path)
|
||||
assert {"workspace", "provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
|
||||
assert {"workspace", "provider_name", "model", "todo_stack", "access_grants", "last_prompt_tokens"} <= columns
|
||||
assert session.sid is not None
|
||||
|
||||
|
||||
@@ -335,10 +350,10 @@ async def test_partially_migrated_legacy_db_noops_remaining_steps(tmp_path: obje
|
||||
session = await store.create_session(name="partial", workspace=tmp_path)
|
||||
await store.close()
|
||||
|
||||
assert await _user_version(db_path) == 3
|
||||
assert await _user_version(db_path) == 4
|
||||
columns = await _session_columns(db_path)
|
||||
assert "workspace" in columns
|
||||
assert {"provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
|
||||
assert {"provider_name", "model", "todo_stack", "access_grants", "last_prompt_tokens"} <= columns
|
||||
assert session.workspace is not None
|
||||
|
||||
|
||||
@@ -350,8 +365,8 @@ async def test_stamped_db_skips_migrations_on_reopen(tmp_path: object) -> None:
|
||||
db_path = tmp_path / "stamped.db"
|
||||
store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
|
||||
await store.close()
|
||||
assert await _user_version(db_path) == 3
|
||||
assert await _user_version(db_path) == 4
|
||||
|
||||
store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
|
||||
await store.close()
|
||||
assert await _user_version(db_path) == 3
|
||||
assert await _user_version(db_path) == 4
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""``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)
|
||||
@@ -6,6 +6,7 @@ import inspect
|
||||
|
||||
from plyngent.agent import ToolRegistry, ToolTag, tool
|
||||
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.chat import CHAT_TOOLS
|
||||
from plyngent.tools.file import FILE_TOOLS
|
||||
@@ -18,7 +19,7 @@ from plyngent.tools.vcs import VCS_TOOLS
|
||||
def test_default_tool_names_match_group_lists() -> None:
|
||||
register_builtin_tools()
|
||||
selected = default_tool_definitions(surface="local")
|
||||
groups = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS, *NET_TOOLS]
|
||||
groups = [*ACCESS_TOOLS, *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 len(selected) == len(groups)
|
||||
|
||||
|
||||
@@ -3,13 +3,18 @@ from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
from plyngent.tools import (
|
||||
AccessMode,
|
||||
WorkspaceError,
|
||||
active_workspace_policy,
|
||||
check_command_allowed,
|
||||
get_workspace_root,
|
||||
mode_covers,
|
||||
parse_access_mode,
|
||||
resolve_path,
|
||||
set_command_denylist,
|
||||
set_path_denylist,
|
||||
)
|
||||
from plyngent.tools.context import SessionState, bind_session
|
||||
|
||||
|
||||
def test_resolve_relative_and_absolute(workspace: object) -> None:
|
||||
@@ -27,6 +32,89 @@ def test_escape_rejected(workspace: object) -> None:
|
||||
_ = 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:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
Reference in New Issue
Block a user