diff --git a/README.md b/README.md index 1b497cc..718471e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A local-first AI shell harness: one terminal conversation that can answer, inspe [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) -ShellPilot runs a small local model through [Ollama](https://ollama.com) and gives it a tight set of structured tools — file reads, anchored edits, command execution, planning, memory, and optional web grounding. The model proposes; you approve. Command risk is classified by a deterministic policy engine, not by asking the model whether something is safe. Nothing leaves your machine: no cloud model calls, no accounts, no API keys, no telemetry. Web access is off by default, and when enabled, every request is approved one at a time. +ShellPilot runs a small local model through [Ollama](https://ollama.com) and gives it a tight set of structured tools — file reads, anchored edits, command execution, planning, memory, and optional web grounding. The model proposes; you approve. Command risk is classified by a deterministic policy engine, not by asking the model whether something is safe. By default, nothing leaves your machine: local Ollama only, no accounts, no API keys, no telemetry. Cloud models are opt-in and off by default, gated behind an explicit config switch and a per-session consent prompt. Web access is off by default, and when enabled, every request is approved one at a time. It is built for and tested against `gemma4:e4b` — a model that fits on modest hardware — so the design assumes a capable-but-fallible local model and treats recovery as the main loop, not an edge case. @@ -16,7 +16,7 @@ It is built for and tested against `gemma4:e4b` — a model that fits on modest **Deterministic safety and command approval.** Every command is classified by a policy engine that inspects the executable, arguments, shell metacharacters, file targets, the workspace boundary, and known destructive patterns. The classification — and the plain-language explanation of *why* a command is risky — is produced by code, not the model, so the model can never talk a `rm -rf` down to "low risk." Agent commands always run with `shell=False`; the model never gets a raw shell. High-risk commands require you to type `run`. -**Local-first and private.** The only model backend is local Ollama. Sessions, memory, logs, plans, and audit trails stay on disk where you can read them. There is no telemetry and no hosted call path. The single optional source of network egress — web grounding — is off until you set it in config, and every search and page fetch is individually approved in every profile. +**Local-first by default and private.** The default model backend is local Ollama — sessions, memory, logs, plans, and audit trails stay on disk where you can read them. There is no telemetry. Cloud models are opt-in, off by default, and require an explicit config change and per-session consent before anything leaves the device. The single optional source of network egress in a local session — web grounding — is off until you set it in config, and every search and page fetch is individually approved in every profile. Beyond the spotlight: @@ -85,20 +85,16 @@ ShellPilot is developed and tested on **macOS** (Apple Silicon) and is **continu ## A session -A representative session in the default `balanced` profile (risk badges are colored chips in the terminal, shown here as text): +A representative session in the default `balanced` profile (risk badges are colored chips in the terminal, shown here as text). On launch, ShellPilot v0.10.0 prints a panel banner — a block-art logo alongside Commands, Tips, Workflow-skills, and Recent-sessions sections — then drops to the prompt; the transcript below picks up there: ```text -ShellPilot 0.9.0 -gemma4:e4b · balanced · /help for commands - -~/my-project · gemma4:e4b · balanced +~/my-project · gemma4:e4b · balanced · ● local 6% ctx ❯ what does this repo do? This is a CLI that counts lines in source files. The entry point is src/cli.py, which calls count_lines() in src/counter.py ... - 3.1s · 0.4k tokens · ctx 6% -~/my-project · gemma4:e4b · balanced +~/my-project · gemma4:e4b · balanced · ● local 11% ctx ❯ fix the off-by-one in count_lines and run the tests ╭─ Plan · 20260620-141502-fix-off-by-one ───────────╮ @@ -121,18 +117,21 @@ gemma4:e4b · balanced · /help for commands + for i in range(0, len(lines)): MEDIUM tool CWD: ~/my-project - Approve? [y/n] y + Approve? [y]es / [e]dit / [n]o y ⎿ ✓ applied 1 change ⏺ run_command(argv=['python', '-m', 'pytest']) + + MEDIUM command · runs arbitrary python code + CWD: ~/my-project + Approve? [y]es / [e]dit / [n]o y ⎿ ✓ 14 passed in 0.31s All steps complete. Fixed the off-by-one (the loop skipped the first line) and the suite passes. - 9.4s · 1.2k tokens · ctx 11% ``` -Read-only tools and low-risk commands (like `pytest`) run automatically under `balanced`; a write asks first and shows the diff. A high-risk command is different again — it carries the deterministic purpose explanation and refuses a plain `y`: +Read-only tools and low-risk commands (`ls`, `git status`, an in-workspace `cat`) run automatically under `balanced`; anything that runs code or writes — including a test run — asks first, and a write shows the diff. A high-risk command is different again — it carries the deterministic purpose explanation and refuses a plain `y`: ```text ⏺ run_command(argv=['rm', '-rf', 'build/']) @@ -152,11 +151,11 @@ Read-only tools and low-risk commands (like `pytest`) run automatically under `b | **Tools** | `read_file`, `list_dir`, `search_text`, `write_file`, `patch_file` (anchored edits), `run_command` (`shell=False`), `env_info`, plus planning (`propose_plan`, `update_plan`), memory (`memory_read`, `memory_propose_update`), and `view_image`. Flat schemas, kept few on purpose — small models degrade as tool count and schema complexity grow. | | **Security profiles** | `supervised` asks before every side-effecting tool and command; `balanced` (default) auto-runs read-only tools and low-risk commands and asks for writes, installs, deletes, network, and anything risky. | | **Planning** | Tasks needing three or more steps produce a visible plan file under `.shellpilot/tasks/`, approved before execution, updated as work progresses, with a single end-of-plan summary. | -| **Skills** | Trigger-driven markdown guidance injected only when relevant (a plan is live, web is enabled, always-on, or opted in). Built-ins cover planning, context management, web grounding, and skill authoring. | +| **Skills** | Trigger-driven markdown guidance injected only when relevant (a plan is live, web is enabled, always-on, or opted in). Built-ins cover planning, context management, web grounding, and skill authoring. Opt-in workflow skills: `debugging`, `verification`, `code-review`, `git-workflow` — each with a lean injected body routing to deeper on-demand docs via `skill_read`. | | **Progressive disclosure** | Deeper skill docs are read on demand through the `skill_read` tool rather than injected into every prompt; active skills advertise their readable docs in a one-line menu. New in v0.9.0. | | **Web grounding** | Opt-in `web_search` and `web_fetch`, off by default, network-approved per request in every profile. The model is guided to fetch sources before asserting facts and to re-search rather than invent URLs. | | **Memory** | Global and project memory as plain JSON; the model proposes, you approve each change. Secrets are redacted before disk. | -| **Manual shell** | `/shell` opens a raw `shell=True` session the model is not part of; `/exit-shell` returns. | +| **Manual shell** | `/shell` opens a raw `shell=True` session the model is not part of; `/exit-shell` returns. `!` runs one command through the same audited path without entering the loop; bare `!` opens the loop. | | **Image input** | `/attach ` stages a PNG/JPG/GIF/WebP image for the next message when the active model supports vision. | | **Sessions & audit** | Conversations journal to `.shellpilot/sessions/`; `--resume` restores them (active plan included); `/export` writes markdown. Approvals, commands, edits, and config changes log as redacted JSONL. | @@ -190,7 +189,6 @@ Inside a session, plain language is the primary interface; slash commands contro | `/context` | Per-block context breakdown: each system-prompt block with source, token estimate, and injection state. | | `/compact`, `/compact status`, `/compact auto on\|off` | Compact context now; show usage; toggle auto-compaction. | | `/memory show`, `/memory add `, `/memory forget `, `/memory compact` | Inspect and curate stored memory. | -| `/prefs show`, `/prefs edit` | Inspect behavior preferences; show memory file paths. | | `/config show`, `/config edit`, `/config reload` | Print resolved config; show the config path; reload from disk. | | `/config set `, `/config unset `, `/config reset` | Set, remove, or clear runtime overrides (persisted in `overrides.json`). | | `/cwd`, `/cwd set ` | Show or change the workspace boundary. | @@ -198,14 +196,15 @@ Inside a session, plain language is the primary interface; slash commands contro | `/export ` | Export this session's transcript to markdown. | | `/attach ` | Stage an image for your next message; bare `/attach` lists staged images. | | `/shell`, `/exit-shell` | Enter and leave Manual Shell. | +| `!`, `!` | Run one command through the audited manual-shell path (raw shell, same as `/shell`) without entering the loop; bare `!` opens the Manual Shell loop. | | `/doctor` | Run the doctor checks from within a session. | -| `/exit`, `/quit` | Exit ShellPilot. | +| `/exit` | Exit ShellPilot. | ## Configuration Config is a user-owned TOML file. ShellPilot never rewrites it — only the program-managed `overrides.json` (set via `/config set`) is self-healing. The file lives in the platform-native config directory via `platformdirs`: on macOS `~/Library/Application Support/shellpilot/`, on Linux `~/.config/shellpilot/`. `shellpilot config edit` prints the exact paths. A `/.shellpilot/config.toml` can override the user file per project. -Settings resolve highest-wins: CLI flags → a fixed set of `SHELLPILOT_*` env vars (model, profile, Ollama URL, color, glyphs) → `overrides.json` → project config → user config → defaults. Most keys — including `tools.web` — have no env override by design. +Settings resolve highest-wins: CLI flags → a fixed set of `SHELLPILOT_*` env vars (model, color, glyphs) → `overrides.json` → project config → user config → defaults. Egress and safety keys (`tools.web`, `model.base_url`, `model.allow_cloud`, `runtime.security_profile`) have no env override by design — an ambient env var must never enable network egress or downgrade the safety posture. ```toml [model] @@ -232,7 +231,27 @@ theme = "default" glyphs = "auto" # auto | unicode | ascii ``` -`[tools] web` and `[skills] enabled` are config-file-only by design: enabling network egress or activating skills must be a deliberate edit, not something an env var or `/config set` can flip. User skills live in `/skills//SKILL.md`. ShellPilot also reads behavior instructions from `AGENTS.md` in your config directory (global) and the workspace root (project) at session start; it follows them and never writes those files. +`[skills] enabled` (and `[model.options]`) are config-file-only: they can only be changed by editing `config.toml`, never via an env var, `overrides.json`, or `/config set`. Egress and safety keys (`[tools] web`, `[model] base_url`, `[model] allow_cloud`, `[runtime] security_profile`) can be set in `config.toml` or via a confirm-gated `/config set` — never silently by an env var. `/config set` shows an amber warning and requires explicit confirmation before persisting any of these to `overrides.json`. User skills live in `/skills//SKILL.md`. ShellPilot also reads behavior instructions from `AGENTS.md`: the one in your config directory (global) is always loaded, while the workspace-root one (project) is gated behind a trust-on-first-use prompt shown the first time it is seen or whenever its contents change. It follows them and never writes those files. + +### Cloud models (opt-in) + +Cloud models are **off by default**. To use an Ollama cloud model (any model whose name ends in `-cloud`, e.g. `nemotron-3-nano:30b-cloud`), add this to your `config.toml`: + +```toml +[model] +allow_cloud = true +default = "nemotron-3-nano:30b-cloud" +``` + +`[model] allow_cloud` has **no environment-variable override** — an ambient env var must never enable cloud egress. It can be set in `config.toml` or via a confirm-gated `/config set` (which shows an amber warning and persists to `overrides.json`); enabling it must be a deliberate act either way. Whichever way it was set, the per-session consent prompt below is the real egress boundary. + +With `allow_cloud = true`, ShellPilot shows an honest disclosure prompt before any data leaves the device and asks for explicit **y/N** consent (defaulting to **N**). Declining — or running non-interactively — fails closed and the session does not start. Consent is per-session and never persisted; every launch re-asks. + +**What the disclosure covers:** when a cloud model is active, the entire prompt — file contents, command output, memory the model reads — is sent to the provider. Best-effort outbound redaction runs, but it is regex-based and not a confidentiality guarantee (novel secret formats and image data may egress unredacted). The provider's data retention, training, and jurisdiction are outside ShellPilot's control. ShellPilot records a `cloud_consent_granted` event and per-turn `model_request` audit events locally so you can audit what sessions egressed. + +**Local-first (the default) remains the only full-privacy posture.** + +The same gate fires if you switch models mid-session with `/model use `: fresh consent is required before anything loads, and on decline the model does not switch. ## Troubleshooting @@ -240,14 +259,14 @@ glyphs = "auto" # auto | unicode | ascii - **`Ollama binary: not on PATH`** — Ollama is not installed. Get it from [ollama.com](https://ollama.com). - **`Models: none installed`** — pull the default model: `ollama pull gemma4:e4b`. - **First turn is slow** — the model cold-starts on the first prompt. ShellPilot preloads the selected model at boot and `keep_alive` keeps it warm between turns; subsequent turns are much faster. -- **Web tools missing** — `web_search`/`web_fetch` only register when `[tools] web = true` in `config.toml`. There is no env-var or runtime toggle for it. +- **Web tools missing** — `web_search`/`web_fetch` only register when `[tools] web = true`. Set it in `config.toml`, or via a confirm-gated `/config set tools.web true` (it takes effect next session). There is no env-var toggle for it. - **A command was rejected before any prompt** — a command that can't start (missing executable, packed shell line, stray shell operator) is rejected deterministically and never spends an approval; correct the arguments and retry. Look for a `did you mean` suggestion. ## Design principles - **Determinism where it earns its place.** Safety, correctness, and control flow are deterministic — risk classification, the approval gate, anchored read-before-write, pre-flight command checks, plan completion. The model's general capability is not babysat; the harness scaffolds the structure of the interaction, not the model's every output. - **Fix problems in the harness, not the prompt.** Bad behavior is corrected by deterministic mechanism, never by telling the model "don't do that." -- **Local-first, no account.** Ollama is the only backend. State stays on disk; the only optional egress is per-request-approved web grounding with no keys. +- **Local-first by default, no account.** Local Ollama is the default and recommended backend. State stays on disk; the only optional egress in a local session is per-request-approved web grounding with no keys. Cloud models are opt-in, off by default, and require explicit per-session consent. - **Small-model focus.** Built and validated on `gemma4:e4b` on an 8 GB machine. Prompts, tool schemas, retries, and grounding guidance are sized for that baseline, with room to dial scaffolding down as models improve. - **Docs as spec.** [`docs/DESIGN.md`](docs/DESIGN.md) is the spec of record and ships in the same commit as the behavior it describes. @@ -265,13 +284,14 @@ python scripts/benchmark_model.py --model gemma4:e4b --trials 10 ## Status and roadmap -Current release: **v0.9.0** — skill progressive disclosure. Recent milestones: +Current release: **v0.10.0** — opt-in cloud models. Recent milestones: - **v0.7.x** — Skills v2 with trigger-driven built-in guidance and read-only resources; instant high-risk approvals generated deterministically from classifier reasons. - **v0.8.x** — web-grounding quality and hardening for small local models: fetch-before-answer, discover-first query shaping, fetch-recovery, current-generation checks; planner hardening for a single end-of-plan summary and idempotent re-proposals. - **v0.9.0** — progressive disclosure: a `skill_read` tool and a readable-docs menu let skills carry depth without inflating every prompt. +- **v0.10.0** — security hardening pass (policy tightening, terminal-output sanitization, DNS rebinding guard, audit/session file modes, config-key hardening, AGENTS.md TOFU); opt-in cloud models behind `[model] allow_cloud` with a per-session consent gate, fail-closed non-TTY path, best-effort outbound redaction, `cloud_consent_granted` and `model_request` audit events, honest system-prompt when egressing, and an unmistakable active-cloud indicator (☁ status bar + amber model name + `/status` locality); a persistent status bar (directory · model · profile · locality + context %); four opt-in workflow skills (debugging, verification, code-review, git-workflow); boot banner with cheat-sheet and amber/green model-locality color; streamlined one-key boot picker; reject-and-steer `[e]dit` tool/command approvals; a `!` one-shot manual-shell escape; resolved-path display in approval panels; full-width diff bars; `/config` command consolidation; approval diff scrolling reveal for long diffs. -Later candidates include richer workflow skills (debugging, verification, review, git), controlled skill-script execution under its own safety design, opt-in cloud models behind an explicit per-session consent gate, a `trusted-local` profile, and `/undo`. +Next up is **v0.10.1** — a full-screen TUI input dock (framed input box, queueable input, completion-menu integration) deferred from v0.10.0. Later candidates include controlled skill-script execution under its own safety design, a `trusted-local` profile, and `/undo`. ## License diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4b98b66..7e9ae6b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,16 +1,16 @@ # ShellPilot Design -Status: Current implementation design through v0.8.0, with historical rebuild notes retained -Date: 2026-06-14 +Status: Current implementation design through v0.10.0, with historical rebuild notes retained +Date: 2026-06-21 Repository: `/Users/lavin/Projects/ShellPilot` ## 1. Purpose -This document defines ShellPilot's design as a modular, local-first Python AI harness. Early sections retain the original rebuild rationale from 2026-06-10; later release-settled sections describe the current implementation through v0.8.0. +This document defines ShellPilot's design as a modular, local-first Python AI harness. Early sections retain the original rebuild rationale from 2026-06-10; later release-settled sections describe the current implementation through v0.10.0. The rebuilt project should feel closer to a local coding and shell partner than a menu-driven chatbot. The user should be able to open one CLI conversation, ask questions, ask for project inspection, request command execution, approve plans for complex work, and use a manual shell when they want direct control. -The system remains local-only through Ollama. Gemma 4 is the default and primary supported model family. The design intentionally avoids broad multi-provider abstractions in the first version because different model families vary widely in tool calling, reasoning behavior, streaming, and instruction following. +The system is local-first through Ollama: by default every model call is local, and cloud/remote models are an explicit, off-by-default opt-in (section 15.2). Gemma 4 is the default and primary supported model family. The design intentionally avoids broad multi-provider abstractions in the first version because different model families vary widely in tool calling, reasoning behavior, streaming, and instruction following. ## Safety Scope @@ -18,7 +18,7 @@ This project is a local developer productivity harness. Security-related feature ## 2. Original Repo Baseline -This section is historical context from the initial rebuild plan. The repository has since shipped the rebuilt architecture through v0.8.0, but these observations explain why the current boundaries exist. +This section is historical context from the initial rebuild plan. The repository has since shipped the rebuilt architecture through v0.10.0, but these observations explain why the current boundaries exist. The pre-rebuild repository already proved several valuable ideas: @@ -97,7 +97,7 @@ Recommendation (superseded by the settled name): ### 5.1 Local First -All model calls happen through local Ollama. No telemetry, cloud sync, or hosted API is part of the product. The only network egress is the optional, off-by-default web grounding tools: they contact only the search provider and pages the user approves per request, with no API keys. +By default, all model calls happen through local Ollama. No telemetry, cloud sync, or automatic upload is part of the product. Cloud/remote models are an explicit opt-in, off by default behind the `[model] allow_cloud` switch (changeable only by a deliberate act — a `config.toml` edit or a confirm-gated `/config set`, never an env var) plus per-session consent (section 15.2) — local-first stays the default and the only full-privacy posture. The only other network egress is the optional, off-by-default web grounding tools: they contact only the search provider and pages the user approves per request, with no API keys. ### 5.2 Gemma 4 First @@ -573,7 +573,7 @@ Planning is NOT required (do the action directly) when: - The task is a direct answer or single read-only inspection. - The task is a single command or a single file edit. -- The task is a simple low-risk command like `pwd` or `python -m pytest`. +- The task is a simple low-risk command like `pwd` or `git status`. ### 11.2 Plan Shape @@ -1217,7 +1217,7 @@ Examples: | Pattern | Risk | |---|---| | `ls`, `pwd`, `git status` | Low | -| `python -m pytest` | Low or medium depending on config | +| `python -m pytest` | Medium (runs arbitrary project Python) | | `pip install` | Medium | | `git commit` | Medium | | `git push` | Medium or high depending on config | @@ -1257,13 +1257,27 @@ File writes should be limited to the workspace unless: The boundary must be clear in the UI. Relative paths are resolved against the workspace before the boundary test, and `rm` targets are boundary-checked in the same way as other write commands (v0.5.2). +**Display-integrity invariant (v0.10.0).** Every user-facing path display — the tool-call line, the approval-panel head, and the write/patch diff-panel title — is derived from the *same* `resolve_in_workspace` result the tool acts on, never from the raw model argument. A spoofing path (`..` segments, `./x/../y`, symlink, trailing junk) is shown as its resolved, workspace-relative target, so the file the user approves is always the file actually touched; a path that escapes the boundary renders an honest `` marker rather than a fabricated-looking in-workspace path. The single helper `workspace_display(workspace, raw_path)` (`tools/base.py`) is the one display formatter, layered over the canonical resolver — there is no second, divergent resolution. This is a display-only change: the boundary checks and the resolution the action uses are unchanged. + +### 14.6 Approval Outcomes And Reject-And-Steer + +An approval prompt has three outcomes, not two — `[y]es / [e]dit / [n]o` (`ApprovalReply`, `policy/approvals.py`): + +- **`[y]es`** — approve and run the action. +- **`[n]o`** (or Enter) — plain decline; the action does not run and the model is told not to retry it. +- **`[e]dit` — reject-and-steer.** The proposed action is **rejected and never runs**; the user types one line of free-text guidance ("Tell the model what to do instead:") which is fed back to the model as the declined tool call's result ("the user declined this action and asks you to do this instead: ``. Propose a corrected action."). The model then proposes a **corrected** action, which re-enters the normal `classify → decide → gate` flow like any other tool call. + +This is *reject-and-steer*, not inline-edit-and-run: the user never edits a command that then executes under the badge it was approved under. **Safety is automatic** because the un-approved action is dropped and the correction is a fresh tool call that is independently classified and re-gated — steering can never smuggle a higher-risk action past the prompt. The outcome is uniform across every approval-gated tool (commands and file writes alike); no per-tool editing logic exists because nothing is edited in place. + +**The HIGH-risk typed-`run` confirm is unchanged.** A HIGH-risk command still requires typing the literal `run` to execute; `[e]` is an additional option at that same prompt that steers without running, and Enter still cancels. Empty guidance after `[e]` is treated as a plain decline (nothing runs). A steered approval is audited as `decision="steered"` on the `approval` event (alongside `approved`/`rejected`). + ## 15. Privacy The product must stay local by default. Privacy requirements: -- No cloud model calls. No telemetry. No remote logging. No automatic upload of files. +- No cloud model calls by default. No telemetry. No remote logging. No automatic upload of files. Cloud/remote models are opt-in and off by default; enabling one is gated by `[model] allow_cloud` plus per-session consent, with an active-cloud indicator and an egress audit as the boundary (section 15.2). - Web grounding is off by default; when enabled, every request is individually approved and audit-logged (query/URL, redacted). - No reading sensitive paths unless relevant and approved. @@ -1289,6 +1303,43 @@ Reads of these are gated deterministically, never by model judgement. The `read_ `search_text` applies the same gate to directory traversal: files whose path components name a secret are skipped (their contents are never read) unless `allow_sensitive_reads = "always"`, and the tool result appends a deterministic note naming up to three skipped files and pointing at `read_file` or the `"always"` setting. An explicit sensitive path passed as the search root is gated by the classifier exactly like `read_file`; once that gate authorizes it (auto under `"always"`, on approval under `"ask"`, never under `"never"`), the approved sensitive root is searched in full — the traversal skip applies only to sensitive files encountered incidentally under a non-sensitive root. Listing directory names (`list_dir`) is not a content read and is never gated. +### 15.1 Egress Chokepoint (v0.10.0) + +When the model endpoint is **remote**, the entire prompt (system prompt, AGENTS.md, memory, file contents, command output) leaves the device — the prompt itself is an exfiltration channel that no per-action approval gate intercepts. The runtime owns a single locality signal (`_is_egressing()`, which delegates to the shared `is_egressing(model, base_url)` predicate in `config/model.py` — true when the model `base_url` is not loopback **or** the model is a cloud model — `is_cloud_model(name)`, the Ollama cloud tag (`:cloud`, or a sized `-cloud` such as `:31b-cloud`, matched on the tag after the final `:`), which egresses to the provider even through a localhost Ollama proxy) and applies two controls at the one chokepoint where every **conversational** model request passes (`conversation.py` tool loop): + +- **Outbound redaction (best-effort defence-in-depth, not a guarantee).** On an egressing turn, when `privacy.redact_secrets` is on, a **redacted copy** of the outbound messages is sent (`redact_secrets` on content, `redact_structure` on tool-call arguments) — the in-memory history is never mutated. A **loopback turn is sent byte-identical** (no copy, zero behaviour change). This is regex-based and conservative: **novel secret formats are missed**, and **image/base64 data is not redactable here and egresses unredacted**. It reduces accidental credential leakage to a provider; it is not a confidentiality guarantee. Local-first remains the only full privacy posture. +- **Egress visibility (audit).** Every egressing model request emits a `model_request` audit event (host/model/counts only — never message bodies; section 22), and every `SideEffect.NETWORK` tool call that actually runs emits a `web_egress` event. Together these record *what left the device* without recording its contents. + +**Accepted residual — `/memory compact`.** One model call bypasses this chokepoint: the `/memory compact` preference optimization (`SlashDispatcher._memory_compact`, `slash.py`) calls the Ollama client directly, outside the `conversation.py` tool loop, so on an egressing session it carries no `model_request` audit event and no outbound-redaction pass. This is an **accepted residual**, not a consent failure: per-session consent (§15.2) is granted before any model call, so this call is already covered by the boundary; and what it sends is stored preferences only (short behaviour strings — not history, file contents, or command output), which §16.4 already requires must not contain secrets. The open gap is audit-completeness (a silent egress the trail does not count) and the missing best-effort redaction pass. Routing `_memory_compact` through the chokepoint to close the audit-undercount gap is a planned hardening follow-up. (Context compaction — `/compact`, §20.2 — is unaffected: it makes no model call, only in-memory message dropping.) + +### 15.2 Cloud Models — Opt-in Egress Consent (v0.10.0) + +Cloud/remote models are **opt-in and off by default**: the local-first posture is the product's core promise, so enabling a model that egresses is a deliberate, security-gated act. A default localhost session is byte-identical to the pre-v0.10.0 program — no new prompt, no `allow_cloud` needed, the system prompt unchanged. + +A session **egresses** when the model is a cloud model (`is_cloud_model(name)` — the Ollama cloud tag, `:cloud` or a sized `-cloud`, matched on the segment after the final `:` so both forms are caught; the prompt leaves the device even though the daemon proxies it through localhost) **or** the endpoint `base_url` is non-loopback (`is_loopback_url` in `llm/ollama.py`). This combined predicate is `is_egressing(model, base_url)` in `config/model.py` — one source of truth shared by the boot consent gate, the runtime egress chokepoint, the active-cloud UI indicator, and `/status`, so every trigger agrees on what counts as off-box. The cloud-models case is the primary feature: `base_url` stays `localhost`; only the model name signals egress. + +Three layered controls form the consent boundary: + +- **`[model] allow_cloud` — the master egress switch (default `false`).** It is **high-stakes and boot-only** (in both `HIGH_STAKES_KEYS` and `BOOT_ONLY_KEYS`, absent from `ENV_MAP`): it is settable via a `config.toml` edit or a confirm-gated `/config set` (which persists through the program-managed `overrides.json`; boot-only, so the effect is next launch) — but **never via an env var**, and never via overrides written by anything but an explicit user `/config set`. The same invariant applies to `tools.web` and `model.base_url`. Whatever the setter, the per-session consent gate below is the real boundary. With `allow_cloud` off, a cloud/remote model is refused at boot with a message pointing at the switch; no model is loaded. +- **Per-session consent (re-asked every launch, never persisted).** With `allow_cloud` on, an egressing session shows an honest disclosure — the model runs off the device; the entire prompt (file contents, command output, memory the model reads) is sent to the provider and may be **unredacted**; under the `balanced` profile low-risk actions auto-run and can send data without a per-action prompt; the provider's retention/training/jurisdiction are outside ShellPilot's control — followed by a simple **y/N prompt that defaults to No** (Enter, EOF, or anything but an explicit yes declines). The session keeps the `balanced` profile (no profile change); the disclosure discloses that risk rather than silently downgrading capability. +- **Fail-closed ordering.** The consent gate (`_resolve_cloud_consent`, a testable module-level helper mirroring the AGENTS.md trust gate) runs at boot **strictly before** the first prompt-bearing call (`_preload` → `model_context_length` → `chat`). A decline (or a non-interactive/non-TTY session, which fails closed because consent cannot be obtained) returns before any model load — **no prompt data egresses**. The local availability gate (`chosen not in installed`) is skipped for cloud names, since cloud models are absent from the local `/api/tags`; the typo-catch survives for local names. `/model use ` mid-session mirrors the same gate: a cloud/remote target requires `allow_cloud` and fresh consent before `set_model`/`_preload`; on reject it neither switches nor preloads. + +**System-prompt honesty.** The base prompt's claim that ShellPilot runs "entirely on this machine" with "no independent network access" is **false when egressing**, so it is conditional: a non-egressing (local) session keeps the byte-identical line (zero regression on the gemma4 baseline); an egressing session replaces it with an honest line stating the session's content leaves the device (`build_system_prompt(is_egressing=...)`, threaded from `_is_egressing()`; `PROMPT_VERSION` 5). + +**Active-cloud indicator (unspoofable).** Three UI surfaces show the locality, all derived from `is_egressing` on the *live* model — never from model output, so the model cannot fake or hide it: + +- **Boot banner** (§31.10) styles the model name amber when the boot session egresses. +- **Persistent status bar** (§31.11). The always-on bar above the prompt carries the locality segment: `☁ CLOUD` (amber, bold) with an amber emphasis across the whole bar when egressing, `● local` (green) otherwise. Each REPL iteration recomputes `is_egressing(runtime.model, base_url)` on the live model and feeds it as the bar's `is_cloud`, so a mid-session `/model use ` turns it on and switching back to a local model turns it off. The bar's `dir`/`model`/`profile`/`ctx%` come from `runtime.status()`, never from model output, and the user-controlled workspace path is sanitized (§36.2) before render. The chevron also turns amber when egressing. (This replaced an earlier separate bold-amber header line; the indicator now lives in one place — the bar.) +- **`/status` locality line.** After Model/Profile, `/status` shows `Locality: REMOTE — ` in amber when egressing (the non-loopback host, or `cloud` for a cloud-tagged model on a loopback endpoint) or `Locality: local` in dim otherwise. + +The status bar is rendered as the input's `bottom_toolbar` with explicit per-fragment colors (prompt_toolkit's default reverse-video toolbar styling is cleared), so the locality segment renders in color in the live terminal. The non-TTY `PlainInput` path has no bar — it cannot egress (consent fails closed off a TTY), so there is nothing to indicate there. + +**Audit.** When the AuditLogger is constructed (after the gate, by which point consent has already been granted), an egressing session records a single `cloud_consent_granted` event (`model`, endpoint `host`) — the consent record alongside the per-turn `model_request` egress-visibility events. + +**Boundary, not guarantee.** Consent is *the* boundary: once granted, the prompt egresses. The best-effort outbound redaction from §15.1 is defence-in-depth layered behind it, not a confidentiality guarantee (regex-based, misses novel secret formats, leaves image/base64 unredacted). The disclosure says so plainly; **local-first remains the only full privacy posture.** + +**Accepted residual — metadata probe before consent.** At boot, `client.health()` and `list_models()` issue `GET /api/tags` against `base_url` *before* the consent gate. For the primary cloud-model case `base_url` is loopback, so this is a purely local call and nothing egresses (Ollama proxies only the actual model load, which is gated). For a **non-loopback `base_url`** these are a **metadata-only probe** (no prompt content) to the endpoint the user *deliberately configured in `config.toml`*, made before consent. This is documented and accepted: it carries no prompt/file/memory data, and the user already chose that endpoint by a deliberate act (a `config.toml` edit or a confirm-gated `/config set` of `base_url`). All **prompt-data** egress remains consent-gated from `_preload` onward. + ## 16. Memory System (v2) Implemented in v0.3.0 (settled 2026-06-11) following this section's design, with these implementation notes: @@ -1334,6 +1385,17 @@ Human-editable files: /AGENTS.md ``` +#### 16.2.1 Project AGENTS.md trust-on-first-use + +The project `/AGENTS.md` is injected as standing "Project instructions" with the same authority as ShellPilot's own system prompt. Cloning or running inside an untrusted repository would otherwise silently load attacker-authored standing instructions (and, under opt-in cloud, egress them). The project file is therefore trust-on-first-use (TOFU): + +- The **global** `/AGENTS.md` is always trusted and loaded — it is the user's own file. +- The **project** `/AGENTS.md` is loaded only after the user accepts it. Acceptance is recorded in `.shellpilot/state.json` as the `trusted_agents_md` SHA-256 digest of the file's raw bytes. +- On boot, the current digest is compared to the recorded one. If they match, the file loads without prompting. If it is new or its content has changed since it was last trusted (any byte change flips the digest), the user is re-prompted (default No) and the file is loaded only on acceptance. +- A **non-TTY** session fails closed: the project file is not loaded (no way to obtain consent). + +Storing the digest never clobbers the recorded last-selected model; both keys coexist in `state.json` via read-merge-write. + Structured memory: ```json @@ -1445,6 +1507,26 @@ Project config:/./config.toml On macOS and Windows, use platform-native equivalents via `platformdirs`. +#### Starter config on first edit + +`/config edit` prints the user config path. When no user `config.toml` exists +yet, it first writes a commented **starter template** at that path (creating the +config directory if needed, owner-only `0600`), so the printed path is real and +immediately editable instead of pointing at a missing file. Every key in the +template is commented out, so it is valid TOML that `load_config` accepts and +that resolves to the built-in defaults — an effectively empty config. The +template's comments are honest about the egress/safety model: which keys are +config-file-only (`model.options`, `skills.enabled`) versus high-stakes +(`tools.web`, `model.base_url`, `model.allow_cloud`, `runtime.security_profile`, +settable here or via a confirm-gated `/config set`, never via an env var). + +When a `config.toml` already exists, `/config edit` only prints the path and +**never** rewrites or overwrites it — config.toml stays user-owned (only the +program-managed `overrides.json` is ever written by the program). Writing a +starter happens solely on the explicit `/config edit` action and only when the +file is absent (using `O_EXCL`, so a concurrently-created file is never +clobbered). + #### Workspace harness state `.shellpilot/state.json` stores harness-internal state for a workspace — it is @@ -1531,12 +1613,46 @@ This is the opposite of `config.toml` handling: hand-edited TOML is user-owned and fails loudly with `ConfigError` exactly as today. The harness never writes TOML (`tomllib` is read-only by design). -**`model.options` exclusion** - -`model.options` cannot be set via the overrides layer. It is config-file only -(same rationale as `tools.web` — a sampling change must be an explicit config -act). An entry for `model.options` in `overrides.json` is silently skipped -with a warning. +**Two-tier setter invariant (egress / security)** + +Two named sets in `config/loader.py` constrain how a key may be set: + +*Config-file-only keys (`CONFIG_FILE_ONLY_KEYS`)* — structural tables/lists +with no scalar CLI representation, settable only by editing `config.toml` +(the user or project layer); never via an env var, the program-managed +`overrides.json`, or `/config set`: + +- `model.options` — a sampling-options table; a sampling change must be an + explicit config act. +- `skills.enabled` — the active-skill list must be an explicit config act. + +An entry for either in `overrides.json` is silently skipped with a warning; +`/config set` rejects it with a `ConfigError`; neither has an env-var mapping. + +*High-stakes keys (`HIGH_STAKES_KEYS`)* — egress/safety keys whose runtime +change materially affects whether data leaves the device or the local approval +posture. They are settable via `config.toml` **or** a confirm-gated +`/config set` (which persists through the overrides layer), but are +**deliberately absent from `ENV_MAP`** — an ambient env var is not a +deliberate act, so it can never enable egress or downgrade the security +posture. This env-absence claim is load-bearing. + +- `tools.web` — enables network egress (web search/fetch). +- `model.base_url` — redirects the Ollama endpoint to a different host. +- `model.allow_cloud` — the master cloud-egress switch (default `false`). + Enabling cloud/remote models is also boot-only. See §15.2. +- `runtime.security_profile` — downgrading the security posture (e.g. + `supervised` → `balanced`). Unlike the egress keys this is **live**: the + profile is read per turn, so a confirm-gated `/config set` lowers it this + session (same effect as `/profile use`, the unsaved session-only switch). + +The relaxation rationale: the model can neither type slash commands nor write +`overrides.json` (it lives in the config dir, outside the workspace), and the +per-session cloud-consent gate (§15.2) still fires regardless of how +`allow_cloud` was set — so a confirm-gated `/config set` opens no model/ +injection vector while the amber confirm preserves deliberateness. Because a +high-stakes override persists and outranks `config.toml`, a boot notice lists +any active high-stakes override on every launch (§36.3). **Slash commands** @@ -1553,11 +1669,24 @@ Three slash commands manage the overrides file at runtime: keep_alive preload, etc.); for those a dim note is appended: "takes effect next session". For `model.default` specifically the note adds "use `/model use ` to switch now". + Setting a **high-stakes key** (`HIGH_STAKES_KEYS` — `tools.web`, + `model.base_url`, `runtime.security_profile`, `model.allow_cloud`) first + prints an amber warning naming the specific risk (cloud egress / changes the + model endpoint / lowers the safety profile / enables web egress), when it + takes effect (the three egress keys are boot-only → "next session"; + `runtime.security_profile` is read per turn → "this session (next turn)", so a + live safety downgrade is never falsely deferred), and that it persists in + `overrides.json` until `/config unset `; it then requires an explicit + confirm. A decline saves nothing and prints a dim "unchanged." For + `runtime.security_profile` the warning also points at `/profile use ` + for an unsaved, session-only change. The structural config-file-only keys + (`model.options`, `skills.enabled`) are still rejected outright by + `validate_override`. - `/config unset ` — remove the override for ``. If no override exists the command is a silent no-op with a message. After removal the value reverts to the next layer down (project, user, or default), and the - source is shown. `/config reset ` is an alias. + source is shown. - `/config reset` (no key) — clear **all** overrides after y/N confirmation (default No). Reports `cleared N override(s).` All values revert to the @@ -1670,9 +1799,7 @@ web = false Recommended environment variables: ```text -SHELLPILOT_OLLAMA_BASE_URL SHELLPILOT_MODEL -SHELLPILOT_PROFILE SHELLPILOT_CONFIG SHELLPILOT_NO_COLOR SHELLPILOT_UI_GLYPHS @@ -1680,6 +1807,13 @@ SHELLPILOT_UI_GLYPHS Do not require environment variables for normal use. +There is deliberately **no** environment variable for the egress / security +keys (`model.base_url`, `runtime.security_profile`, `tools.web`, +`model.allow_cloud`). An ambient env var (e.g. an inherited `.envrc`) must not +be able to redirect the Ollama endpoint, downgrade the security profile, or +enable network/cloud egress — those require a deliberate act (a `config.toml` +edit or a confirm-gated `/config set`; see the high-stakes keys above). + #### `--model` flag `shellpilot --model NAME` selects the model for a single session without @@ -1853,13 +1987,12 @@ Slash commands are user controls for the harness itself. They should not be the Commands should be predictable, composable, and safe. Destructive app-state commands should ask for confirmation. -Planned commands: +Commands: | Command | Purpose | |---|---| | `/help` | Show available slash commands and short examples. | | `/exit` | Exit the harness. | -| `/quit` | Alias for `/exit`. | | `/clear` | Clear conversation history after confirmation; also cancels the active plan and resets snapshots, diffs, and failure state. | | `/status` | Show current model, profile, cwd, context usage, active plan, and pending approvals. | | `/doctor` | Check Python version, Ollama reachability, model availability, config paths, and workspace access. | @@ -1869,8 +2002,11 @@ Planned commands: | `/profile` | Show active security profile. | | `/profile use ` | Switch security profile for this session only (reverts on restart; set `[runtime] security_profile` in config.toml to make it permanent). `trusted-local` arrives in v2. | | `/config show` | Print resolved config with source layers. | -| `/config edit` | Open or print the user config path for editing. | +| `/config edit` | Print the user config path for editing; if no `config.toml` exists yet, first write a commented starter template (resolving to defaults) at that path. Never overwrites an existing file (§17.2). | | `/config reload` | Reload config from disk. | +| `/config set ` | Validate and persist a single override to `overrides.json`; high-stakes keys require an amber-gated confirmation (§17.3). | +| `/config unset ` | Remove the override for `` (§17.3). | +| `/config reset` | Clear all overrides after `y/N` confirmation (§17.3). | | `/cwd` | Show current workspace boundary and process cwd. | | `/cwd set ` | Change workspace cwd/boundary after confirmation. | | `/tools` | List available tools and whether each is enabled by the active profile. | @@ -1886,19 +2022,17 @@ Planned commands: | `/context` | Show the per-block context breakdown (block name, source, token estimate, injected flag, and skip reason) plus tool schemas, history, and a total against the model context and compact-at thresholds. Reads the same `ContextSnapshot` the live prompt is built from (section 10.5). | | `/logs` | Show recent local audit/session events. | | `/export ` | Export this session's transcript to markdown (default `.shellpilot/exports/.md`). | -| `/memory show` | Show project and behavior memory summaries with entry ids. | +| `/memory show` | Show project and behavior memory with entry ids; preferences are annotated with their (scope, source) and the store file paths are printed. | | `/memory add ` | Add a global behavior preference after confirmation. | | `/memory forget ` | Remove a memory entry after confirmation. | | `/memory compact` | Model-assisted preference optimization, approved before saving (section 16.4). | -| `/prefs show` | Show behavior preferences. | -| `/prefs edit` | Show the memory file paths for hand-editing; `/memory show` reloads. | | `/shell` | Enter Manual Shell mode. | | `/exit-shell` | Return from Manual Shell mode to the assistant. | | `/attach ` | Stage an image file to send with the next user message (vision-capable models only). Path is validated eagerly; bytes are re-read at send time. *(v0.5.0)* | | `/attach` | List currently staged images, or report "No attachments staged." *(v0.5.0)* | | `/skills` | List all discovered skills with root, trigger declarations, enabled/builtin/disabled/invalid status, decision-derived active state, resource/script summaries, skip reasons, and advisory warnings. *(v0.7.0)* | -All commands scheduled for v0.3.0 (memory, prefs, compact auto, export) shipped and appear in the table above. +All commands scheduled for v0.3.0 (memory, prefs, compact auto, export) shipped. `/prefs` was retired in v0.10.0 — it had converged with `/memory show`, which now lists preferences with their (scope, source) and prints the store paths. `/context show` (a redacted dump of the assembled prompt) is deliberately deferred. The `/context` status table does not fully solve prompt inspection — it shows per-block sizes, not the prompt's actual text — but a verbatim dump must not leak secrets. When `show` lands it must reuse the v0.5.2 redaction helpers before printing any block content. @@ -1979,6 +2113,8 @@ The AI is not controlling this mode. Type /exit-shell to return. ``` +A `!` prefix is a one-shot escape into the same audited manual-shell path: `!` runs a single command (raw shell, identical to `/shell`) without entering the shell loop, while a bare `!` opens the loop. It is a human-only escape — the prefix is recognized only at the interactive prompt reader, so model output can never reach it. + ## 22. Logging And Audit Audit logs should be local and structured. @@ -1996,6 +2132,9 @@ Events: - (v2) Memory update. - Config change. - Error. +- **`model_request`** (v0.10.0) — emitted only on an **egressing** turn (a non-loopback model endpoint *or* a cloud model; §15.2). Records *that* a prompt left the device and to where, with **counts only, never message bodies**: `host`, `model`, `locality` ("remote"), `message_count`, `approx_bytes`, `image_count`. A purely local turn emits nothing. This is the egress-visibility record (F10/F12) for what leaves the box. +- **`web_egress`** (v0.10.0) — emitted by the executor when a `SideEffect.NETWORK` tool (`web_search`/`web_fetch`) actually runs (after every gate passes, immediately before the call leaves the box). Records `tool` and the redacted `args` (the AuditLogger redacts a secret in a query/URL). A blocked or user-declined call never ran and emits nothing. +- **`cloud_consent_granted`** (v0.10.0) — emitted once per egressing session, after the user has granted per-session cloud consent at boot (or via `/model use`; §15.2). Records `model` and the endpoint `host` only — the consent record for a session that may egress prompt data. Log entry shape: @@ -2175,6 +2314,8 @@ Progressive disclosure lets the model read a skill's deeper docs on demand rathe **`Readable:` menu.** After the skills-index block, the assembler injects a one-line `"skills readable"` block advertising the on-demand docs of every injected skill: `Readable docs (open with skill_read): : , ; : `. Only skills with ≥1 on-demand resource appear; names are references before templates, deduped per skill. The block is injected only when `trigger_ctx.enabled` is non-empty (mirroring the `skill_read` registration gate exactly — see Registration above), so a default session with no opted-in skills sees no menu and the baseline system text is unchanged. The block is not budget-counted; it is a bounded single line, not skill body content. +**Workflow skills (opt-in showcase, v0.10.0).** Four `ENABLED`-gated builtins exercise progressive disclosure: `debugging` (reproduce → hypothesize → isolate/bisect → fix the cause → verify; refs `method`, `common-traps`), `verification` (run the real check before claiming done; ref `checklist`), `code-review` (correctness/security/clarity/tests/scope; ref `dimensions`), and `git-workflow` (inspect-before-commit, atomic commits, safe undo; refs `commits`, `recovery`). Each ships a lean injected body (~175–185 est tokens) that routes by name to on-demand `references/*.md` the model reads via `skill_read` when relevant — depth without standing context cost. They are opt-in (enable via `[skills] enabled`), so a default session is byte-identical to before. The `git-workflow` guidance is accurate to the policy classifier: `git reset`/`git clean`, branch deletion, and force-push are `RiskLevel.HIGH` (section 14), a plain `git push` is medium. + ## 24. Operational Edge Cases These edge cases should be accounted for in design and tests. They do not all need elaborate v1 implementations, but the runtime should fail clearly and avoid unsafe behavior. @@ -2578,14 +2719,14 @@ Colors are truecolor values; rich downgrades automatically on 256/16-color termi ### 31.2 Prompt -Two-line prompt replacing `[AI] >`: +A bold accent-green `❯` over a persistent status bar (§31.11): ```text -~/Projects/test_project · gemma4:e4b · balanced +~/Projects · gemma4:e4b · balanced · ● local 12% ctx ❯ ``` -Line one is dim ambient context (workspace with `~` abbreviation and middle truncation for long paths, then model, then profile). Line two is a bold accent-green `❯`. Input is provided by `prompt_toolkit`: persistent up-arrow history (state dir `history` file) and tab-completion for slash commands. Plain `input()` fallback when stdin is not a TTY. +Input is provided by `prompt_toolkit`: persistent up-arrow history (state dir `history` file), tab-completion for slash commands, and the always-on status bar rendered as the session's `bottom_toolbar`. The chevron is accent-green for a local session and turns **amber** when egressing (a second at-a-glance cloud cue alongside the bar's locality segment). The non-TTY `PlainInput` fallback (pipes, tests) prints the dim `context_line` (`~/Projects · model · profile`) above a plain `input()` — the bar is a TTY feature, and a non-TTY session cannot egress (cloud consent fails closed off a TTY, §15.2). ### 31.3 Activity lines @@ -2593,7 +2734,9 @@ Tool calls render as `⏺` + bold tool name + dim `(args) · summary`. Results a ### 31.4 Diffs -Diffs render in a rich `Panel` titled with the filename: line-number gutter, full-line subtle red/green backgrounds for removals/additions, and brighter word-level highlight spans on the changed words. Word-level highlighting applies only when a removed/added line pair is similar (`difflib.SequenceMatcher` ratio >= 0.5); pure additions/removals get the full-line background only. Long lines wrap with the background following the text and a blank gutter on continuation rows. Tabs are expanded and control characters sanitized before rendering. +Diffs render in a rich `Panel` titled with the filename: line-number gutter, full-line subtle red/green backgrounds for removals/additions, and brighter word-level highlight spans on the changed words. A changed line is always laid out the conventional unified-diff way — the old text on its own red removal row, then the new text on the next green addition row (removals before additions within a hunk), each carrying its own line-number gutter; the two are never paired onto one visual line. Every removal/addition fills its colored background to a uniform width (the widest changed-line content in the diff), so each renders as a distinct full-width bar rather than a short colored fragment. Word-level highlighting applies only when a removed/added line pair is similar (`difflib.SequenceMatcher` ratio >= 0.5); pure additions/removals get the full-line background only. Long lines wrap with the background following the text and a blank gutter on continuation rows. Tabs are expanded and control characters sanitized before rendering. + +When a file write/edit is proposed, the approval diff appears with a brief scrolling reveal before the prompt: `DiffReveal` (cli/streaming.py) opens a transient `Live` (`vertical_overflow="crop"`) on the main thread and progressively shows the diff rows, scaling the chunk size so the whole reveal finishes within a fixed `TOTAL_DURATION` (~0.6 s) regardless of length — a huge diff adds no real latency. It clears the region before stopping (same clear-before-stop as `ResponseStream.finish`) so a tall reveal never leaks into scrollback. The reveal rides the same motion toggle as the spinner (`[ui] spinner`): it is a no-op when motion is off, on a non-terminal, or for a short diff (at or below `ANIMATE_THRESHOLD = 20` rendered rows), and it never starts until the spinner's own `Live` has fully stopped — two concurrent `Live` on one Console would corrupt the display. After the reveal, the settled panel prints. A long diff settles into a bounded window of `WINDOW_ROWS = 24` rows with a single `… (+N more)` footer (`render_diff(..., max_rows=...)`, `sp.faint`, mirroring `output_truncation`); a short diff prints in full unchanged. The approval semantics are unchanged: the diff is shown, then the y/n (or typed-`run`) prompt, then the write applies only on approval. The source diff is already capped upstream at `MAX_PREVIEW_LINES = 60` (tools/patch.py), so the window/footer is a second display layer on an already-bounded preview. ### 31.5 Approvals @@ -2603,7 +2746,7 @@ Badge blocks: an inverse chip anchors the request, followed by the dim reason or - ` HIGH ` — white on red `#c14949`; keeps the typed-`run` flow, with `"run"` in red. - ` BLOCKED ` — black on amber; used by the roadblock protocol (section 11.6). -The yes/no question is `Approve? [y/n]` — uniform lowercase. It accepts `y`/`yes`/`n`/`no` case-insensitively and **Enter means no**; default-deny semantics are unchanged, only the shouty capital is gone. When color is unavailable, chips degrade to plain `[MEDIUM]` text. +The approval question is `Approve? [y]es / [e]dit / [n]o` — uniform lowercase. It accepts `y`/`yes`/`n`/`no` case-insensitively and **Enter means no**; default-deny semantics are unchanged. `[e]dit` rejects-and-steers: it does not run the action but prompts `Tell the model what to do instead:` for one line of guidance fed back to the model (section 14.6); empty guidance is a plain decline. A HIGH-risk command keeps its typed-`run` gate, with `[e]dit` offered alongside (`Type "run" to execute, [e]dit to steer, or press Enter to cancel`). When color is unavailable, chips degrade to plain `[MEDIUM]` text. ### 31.6 Plans @@ -2628,7 +2771,7 @@ Model responses render as rich Markdown, updated live while tokens stream (`rich Rationale: random pick within an ordered, never-regressing phase progression — coherent story within a turn, variety across turns; no approach/landing phrases because completion time is unknowable. -- After each turn: a faint stats line — `2.1s · 1.4k tokens · ctx 18%`. The ctx percentage turns amber once estimated usage crosses `compact_at_tokens` (section 10.5). +- Context utilization is no longer printed after each turn. It lives in the always-on status bar (§31.11), color-coded as it fills, so the reading is visible at the prompt at all times rather than only on the turn it was emitted. `turn_finished` stays on the `RuntimeUI` protocol (the runtime still computes `TurnStats`) but the terminal UI no longer prints a per-response line. **Labeled spinner states (A10, settled 2026-06-11):** `AviationSpinner.start(label=...)` accepts an optional label (plain `str` or rich `Text`). When set, every frame renders a breathing-beacon glyph (`·` / `✧` / `✦` cycling) in `sp.accent` followed by the label with its rich styling preserved (no longer flattened to plain text), then the dim elapsed suffix. Two specific labels are used: @@ -2644,21 +2787,47 @@ No-label behaviour (flight-phase phrases) is the default. The `ui.spinner = fals - Glyph fallback: `ui.glyphs = "auto" | "unicode" | "ascii"`. The glyph set (`⏺ ⎿ ❯ ✈ ✦ ✧ ☐ ✓ ▶`) maps to ASCII equivalents; `auto` selects ASCII on terminals that cannot encode the Unicode set. - Snapshot tests (section 26.1) cover plan, approval, and diff rendering. +### 31.10 Boot banner + +The boot banner is a bounded-width (`expand=False`) rich `Panel` (`cli/banner.py:render_banner(model, *, is_cloud, profile, skills=(), recent_sessions=())`), printed once after preload and consent resolve. It is laid out as two columns split by a full-height vertical divider. The **left column** centers a welcome line, the block-art fighter-jet logo, the active model name, and a dim sub-line `" · "`. The **right column** stacks sectioned cheat-sheets separated by dim horizontal rules: **Commands** (`/help`, `/plan`, `/skills`, `/status`), **Tips** (`/`, `!`, typing `"run"`), **Workflow skills** (the enabled skill names, or — when none are enabled — the available builtins dim plus a `"/skills to enable"` hint), and **Recent sessions** (up to three prior sessions as `label (age)`, the whole section omitted when there are none). The panel title is `ShellPilot v`. + +The model name is styled **green** for a local session and **amber** for an egressing (cloud/remote) one — `is_cloud` is the boot-time `egressing_session` value (see §15.2), making the banner the first locality cue of the session, reinforced by the sub-line's `local`/`cloud` word. `profile` and `skills` come from `settings.runtime.security_profile` and `settings.skills.enabled`; `recent_sessions` is built from `SessionStore.recent(sessions_dir)` (newest-first `(label, mtime)` pairs, label = a truncated first-user-message snippet or the session's model name) with mtimes mapped to compact relative ages (`_relative_age`). The recent list is captured *before* the current session's metadata is written so a session never lists itself. + +**Jet symmetry:** the jet is rendered as one fixed-width (28-cell) left-aligned `Text` block and centered as a unit via `Align.center(..., width=28)` — never per line with `justify="center"`, which Rich would skew because it strips trailing whitespace, leaving narrow jet rows lopsided in a live terminal. A regression test pins per-row left/right symmetry. + +This sectioned layout replaced the earlier two-column command-only banner. + +### 31.11 Persistent status bar + +A one-line status bar is pinned at the input as `prompt_toolkit`'s `bottom_toolbar`, so it stays static and refreshes on every render. It is the at-a-glance complement to `/status` (§15.2 detailed readout). Layout: + +```text +~/Projects · gemma4:e4b · balanced · ● local 12% ctx +``` + +- **Left:** ` · · · `, joined by faint `·` separators. `dir` is the workspace, home-abbreviated. `model` is **green** local / **amber** egressing. `locality` is `● local` (green) or `☁ CLOUD` (amber, bold). +- **Right:** `% ctx` — context utilization, color-coded **green** (`< 50`) → **amber** (`50–79`) → **red** (`>= 80`). +- **Cloud emphasis:** when egressing, the bar's separators carry a faint amber tint (the prototype's amber "wash" adapted to the terminal) so the whole line reads as cloud at a glance, distinct from a local (faint-grey) bar — alongside the amber model name, the `☁ CLOUD` locality, and the amber chevron. + +`status_bar(...)` (`cli/status_bar.py`) is a **pure** builder mirroring `cli/banner.py`: it takes `workspace`/`model`/`profile`/`is_cloud`/`ctx_pct` and returns a `prompt_toolkit` `FormattedText`, with no I/O. Every field is sourced from runtime/session state (`runtime.status()`), never from model output. The `is_cloud` field is the caller's real `is_egressing(runtime.model, base_url)` value (recomputed each REPL iteration on the live model), keeping the active-cloud indicator unspoofable and harness-rendered (§15.2). `ctx_pct` reuses the runtime's own utilization formula via `ctx_percent(used, total)`. The workspace path is user-controlled, so it is run through the shared `_sanitize_line` control/ANSI strip before render (§36.2) — a crafted directory name cannot repaint the bar. The bar consolidates two readouts that previously printed separately each loop: the cloud indicator (was a bold-amber header line) and context utilization (was a per-turn stats line, §31.8). + ## 32. Model Selection And Preload ### 32.1 Boot Model Picker Settled 2026-06-11. On every interactive boot, ShellPilot presents a numbered list of all models installed in the local Ollama instance so the user can choose which model to run for the session. -**When the picker is shown:** the picker appears when all three conditions hold: the session is interactive (the console is a TTY and `sys.stdin.isatty()` is true), no `--model` flag was passed on the command line, and more than one model is installed. When any condition fails the session model is `settings.model.default` without prompting. +**When the picker is shown:** the picker runs when all three conditions hold: the session is interactive (the console is a TTY and `sys.stdin.isatty()` is true), no `--model` flag was passed on the command line, and more than one model is installed. When any condition fails the session model is `settings.model.default` without prompting. + +**Compact confirm vs full menu:** the first boot in a workspace (no recorded last model, or the last model is no longer installed) shows the full numbered menu, preselected on the last model if present else `settings.model.default`. Every boot after that shows a one-line confirm — `✈ Last flight: ` then `Enter to fly · any other key for the menu` — so the common case (keep flying the same model) is a single Enter. Empty input / EOF / `KeyboardInterrupt` flies the last model; any non-empty key opens the full menu preselected on it. Whichever path resolves, `save_last_model` persists the choice, and the chosen model flows unchanged into the availability gate and the cloud-egress consent gate (§15.2). **List layout:** one row per model — row number, chevron marking the preselected row (`❯` in accent green, a space otherwise), model name in emphasis style, size in GB in dim style, and a dim `untested` tag for any model not in `TESTED_FAMILIES` (see `shellpilot/config/model.py`). Rich named styles from the §31 theme are used throughout; no inline hex. **Preselection:** the row highlighted by default is the last model the user chose in this workspace (from `.shellpilot/state.json`), falling back to `settings.model.default` if the last model is absent from the current install list. -**Input:** the prompt reads `Select a model [Enter = ]`. Accepted input: empty Enter (returns preselect), a 1-based row number, or an exact model name. Anything else re-prompts. `EOFError` and `KeyboardInterrupt` return the preselect silently. After a selection `save_last_model` persists the choice for the next boot. +**Input:** the menu prompt reads `Select a model [Enter = ]`. Accepted input: empty Enter (returns preselect), a 1-based row number, or an exact model name. Anything else re-prompts. `EOFError` and `KeyboardInterrupt` return the preselect silently. After a selection `save_last_model` persists the choice for the next boot. -**Module:** `shellpilot/cli/model_picker.py` — three pure functions (`should_show_picker`, `resolve_preselect`, `choose_model`) with console injected for testability. +**Module:** `shellpilot/cli/model_picker.py` — four pure functions (`should_show_picker`, `resolve_preselect`, `confirm_last_model`, `choose_model`) with console injected for testability. ### 32.2 Model Preload And keep_alive @@ -2711,11 +2880,11 @@ Search quality is a known watch item: DDG HTML results vary by region, UA, and p - Hostname must be non-empty. - Blocked by name: `localhost`, any `*.localhost` subdomain, `0.0.0.0`. - Trailing dots are stripped from the hostname before all checks (e.g. `localhost.` normalises to `localhost`); a hostname consisting entirely of dots is treated as empty and rejected. -- IP literals (including IPv6) are rejected if `is_loopback`, `is_private`, `is_link_local`, or `is_reserved`. Legacy short-dotted numeric forms (e.g. `127.1`) that `ipaddress` cannot parse are retried via `socket.inet_aton` and subjected to the same checks. +- IP literals (including IPv6) are rejected when `not addr.is_global`, covering loopback, private, link-local, reserved, and CGNAT/shared ranges (matching §36.6). Legacy short-dotted numeric forms (e.g. `127.1`) that `ipaddress` cannot parse are retried via `socket.inet_aton` and subjected to the same checks. **Per-hop redirect re-check:** redirects are followed manually (up to `MAX_REDIRECTS = 10` hops). Every redirect destination passes through the same guard before the next connection is made, so a public URL that 302s to a private IP is blocked at the second hop. -**Known limitation:** the guards act on the URL hostname before DNS resolution. A public-looking domain that resolves to a private IP (DNS rebinding) is not caught. DNS pinning is not implemented; the guards cover accidental cases, not adversarial ones. Users requiring stronger SSRF protection should layer a network-level egress filter outside ShellPilot. +**Known limitation:** every hostname is resolved and each resolved address is validated before connecting (§36.6), but the connection is not pinned to the validated IP — pinning would break TLS SNI/cert verification for arbitrary HTTPS. The narrow resolve-then-reconnect rebinding window therefore remains open; users requiring stronger SSRF protection should layer a network-level egress filter outside ShellPilot. **No `robots.txt` by design:** each fetch is an individually user-approved action that behaves like a direct browser visit. Automatic robots.txt compliance would add latency and complexity for no meaningful benefit in a single-user harness where every request is manually gate-kept. @@ -2801,3 +2970,55 @@ The specific incidents that motivated the sandbox idea are addressed without OS ### 35.3 Revisit Condition Real user demand for running ShellPilot in untrusted directories — for example, reviewing unknown repositories or executing agent tasks against code from external sources — would reopen this question with fresh eyes. In that scenario the threat model shifts meaningfully and OS-level confinement becomes more defensible despite the maintenance cost. A future decision should evaluate the sandboxing options available at that time rather than assuming the current Seatbelt or Apple container landscape is unchanged. + +## 36. Security Hardening (v0.10.0) + +v0.10.0 introduces opt-in cloud models (section 15.2), so it shipped with a deliberate, red-team-audited hardening pass and a dedicated security review before release. The audit lens reflects the new threat model. ShellPilot's existing safety layers — deterministic risk classification, per-action approval, the workspace boundary, the sensitive-read gate — exist to protect **the machine from the model**: even a hijacked or prompt-injected model cannot harm the host without passing a gate. Cloud egress adds a second axis: protecting **the user's data from the provider**. The prompt is itself an egress channel that no per-action gate intercepts, so the hardening below closes the gaps that the new axis exposes, alongside fixes to the existing machine-protection layers that the audit surfaced. The fixes are categorized below; exploit mechanics are deliberately omitted. This is hardening for a single-user local tool run in trusted directories — defence-in-depth, not a confinement boundary (section 35). + +### 36.1 Command Classifier — Read-Path Boundary + +The dedicated `read_file`/`search_text` tools enforce the workspace boundary and the sensitive-read gate (section 15); the audit found the `run_command` reader path was a parallel, ungated file-read channel. Hardened in `policy/command_policy.py`: + +- **Reader executables now honour the boundary.** A reader executable (`cat`/`head`/`tail`/`grep`/`rg`/`wc`/`file`/`stat`/`du`) whose path argument resolves **outside the workspace**, or names a secret marker, is escalated to `RiskLevel.HIGH` (always-ask) instead of returning LOW/auto-run. Because reader commands carry `SideEffect.VARIABLE` they can never auto-run silently, so the deterministic over-ask (HIGH → ASK) is the conservative match for the file tools' boundary, rather than threading the `allow_sensitive_reads` setting through the command path. +- **`git` global options feed classification.** A `git` invocation carrying a non-benign global option before the verb (`--git-dir`/`--work-tree`/`--exec-path`/`-C`/`-c` and the like) is treated as at least MEDIUM (ASK), closing an out-of-workspace read primitive that the previous first-non-dash-token verb derivation skipped past. Only `--no-pager`/`--literal-pathspecs` remain benign. +- **`pytest` requires approval.** `pytest` and `python -m pytest` execute arbitrary project Python (conftest/collected modules) at collection time, so the previous LOW carve-out is removed: they now classify MEDIUM (ASK in `balanced`), symmetric with `python script.py`. +- **Path-qualified basenames are distrusted.** When `argv[0]` contains a path separator (`./grep`, `/abs/grep`), the bare-name LOW allowlist no longer applies; a path-qualified executable is classified at least MEDIUM, so a workspace-staged file sharing a trusted command's name cannot auto-run. + +### 36.2 Terminal Output Sanitization + +The active-cloud indicator (section 15.2) is only trustworthy if the model — or untrusted data it surfaces — cannot repaint the terminal over it. Control characters and ANSI escape sequences are now stripped at **every** output sink via the shared `_sanitize_line`/`_CONTROL_CHARS` helper (`cli/render.py`), not only in the diff panel: streamed model text (`cli/streaming.py`), raw command output (`show_command_output`), tool-call/tool-result summaries (`cli/render.py`), and status/error lines (`cli/terminal.py`). Tab and newline are preserved; ESC and C0/DEL bytes are removed before anything reaches the screen, so neither model output nor auto-run command output can forge a status region or spoof an approval prompt. + +### 36.3 Egress / Safety Setter Invariant + +Anything that controls whether data leaves the device, or the local approval posture, is a **deliberate** act — never reachable from an **ambient environment variable**. `config/loader.py` defines `HIGH_STAKES_KEYS` = {`tools.web`, `model.base_url`, `runtime.security_profile`, `model.allow_cloud`}: every one is **absent from `ENV_MAP`** (the `SHELLPILOT_OLLAMA_BASE_URL` and `SHELLPILOT_PROFILE` env redirects were dropped), so no ambient env var can enable egress, redirect the endpoint, or downgrade the profile. This env-absence claim is the load-bearing invariant and holds uniformly across all four keys. + +The four keys are settable two deliberate ways: a `config.toml` edit, or a runtime `/config set` gated by an amber warning + explicit confirmation (the `HIGH_STAKES_KEYS` gate in `SlashDispatcher._config_set`). A confirmed `/config set` persists through the program-managed `overrides.json` and applies at the key's reload time (the three egress keys are boot-only, so the effect is next launch; `runtime.security_profile` is read per turn, so it applies this session — the same live effect as `/profile use`). This is a deliberate relaxation of the stricter original form (which also forbade `/config set` and the overrides layer) for a reasoned trade-off: the model can neither type slash commands nor write `overrides.json` (it lives in the config dir, outside the workspace), and the per-session cloud-consent gate (§36.8 control 2) still fires regardless of how `allow_cloud` was set — so the relaxation opens no model/injection vector, while the amber confirm preserves the deliberateness the invariant exists to guarantee. The two **structural** config-file-only keys (`CONFIG_FILE_ONLY_KEYS` = {`model.options`, `skills.enabled`}) remain fully config-file-only: rejected by `validate_override`, skipped-with-warning in the overrides loop, and absent from `ENV_MAP`. + +Because a high-stakes override persists in `overrides.json` and silently outranks `config.toml`, a confirmed runtime relax would otherwise quietly stay enabled. As a residual-risk mitigation, the boot path (`run_interactive`, after config load) prints one amber line listing any active high-stakes override — e.g. `⚠ Active overrides: model.allow_cloud=true, tools.web=true — these override config.toml; /config unset to revert.` — built by the pure, testable `high_stakes_override_notice` helper. It gates nothing (the consent gate remains the egress barrier); it ensures a set-and-forget egress override is visible on every launch. + +### 36.4 Project AGENTS.md Trust-on-First-Use + +A project `AGENTS.md` is injected as standing instructions with the same authority as ShellPilot's own prompt, so cloning and running in an untrusted repo could load attacker-authored instructions every turn (and, under cloud, egress them turn one). The project (workspace) `AGENTS.md` is now gated behind per-workspace **trust-on-first-use**: its SHA-256 content digest (`project_agents_md_digest`, `memory/agents_md.py`) is recorded in program-managed workspace state (`.shellpilot/state.json`) once the user accepts it. A non-TTY session fails closed (the file is skipped), and because the gate keys on the content digest, **any change to the file re-prompts** before the new content is trusted. The global config-dir `AGENTS.md` stays trusted (it is the user's own). + +### 36.5 Egress Chokepoint and Audit + +A single locality predicate, `is_egressing(model, base_url)` (`config/model.py`), is the one source of truth for whether a session is off-box — true for a cloud model (the Ollama cloud tag — `:cloud` or a sized `-cloud` — via `is_cloud_model`) or a non-loopback `base_url` (`is_loopback_url`, `llm/ollama.py`). At the conversational chokepoint (`conversation.py` tool loop; the `/memory compact` residual is noted in section 15.1): + +- **Best-effort outbound redaction** runs on egressing turns when `privacy.redact_secrets` is on — a **redacted copy** of the outbound messages is sent (the in-memory history is never mutated); a loopback turn is sent byte-identical. It is regex-based defence-in-depth (misses novel formats, cannot redact image/base64), not a confidentiality guarantee. +- **Egress-visibility audit** emits a `model_request` event per egressing turn (host/model/counts only — never message bodies) and a `web_egress` event before each `SideEffect.NETWORK` tool call (section 22), recording *what left the device* without its contents. + +### 36.6 DNS-Rebinding SSRF Defense in `web_fetch` + +The `web_fetch` fetcher previously validated only literal IP addresses, so a hostname resolving to a loopback/private/link-local/metadata address bypassed the guard. `_check_url` (`web/fetch.py`) now resolves every hostname (`socket.getaddrinfo`) and validates **each** resolved address with a single `not addr.is_global` check (covering loopback, RFC-1918 private, link-local, and CGNAT/shared metadata ranges), on the initial URL and on **every redirect hop**. Accepted residual, documented in code: the connection is not pinned to the validated IP (pinning would break TLS SNI/cert verification for arbitrary HTTPS), so the narrow resolve-then-reconnect rebinding window is not closed — acceptable on a single-user box where `web_fetch` is `SideEffect.NETWORK` (always-ask) in every profile. + +### 36.7 At-Rest File-Permission Parity + +The two highest-value at-rest artifacts — session transcripts and the audit log — were created world/group-readable (0644) under the default umask while lower-value state (memory/overrides) was already 0600. Both now create their file at mode 0600 and parent directory at 0700 (`os.open(..., O_CREAT, 0o600)`, `persistence/sessions.py` and `audit_store.py`), bringing parity. Exploitation requires a co-resident second local user — absent from the single-user laptop deployment — so this is local-first-acceptable hardening, but the split was clearly unintended and the fix is trivial. + +### 36.8 Cloud Consent Triad + +The opt-in cloud feature (section 15.2) rests on four enforced controls, summarized here as the security spine: (1) `[model] allow_cloud` defaults to **`false`** and can be enabled only by a deliberate act — a `config.toml` edit or a confirm-gated `/config set`, never an ambient env var (section 36.3); (2) a **fail-closed per-session consent gate** (`_resolve_cloud_consent`) runs at boot strictly before any prompt-bearing call and declines on a non-TTY, EOF, Enter, or anything but an explicit yes — so a decline egresses no prompt data; (3) an **unspoofable active-cloud indicator** (boot banner, persistent bottom status bar [§31.11], `/status` locality) derived from `is_egressing` on the live model, never from model output, with the bar's user-controlled workspace path sanitized at the render sink (§36.2); and (4) a `cloud_consent_granted` audit event recording the consent. Consent is *the* boundary: once granted, the prompt egresses, with the best-effort redaction above layered behind it. Local-first remains the only full-privacy posture. + +### 36.9 Approval-Path Display Integrity + +An approval is only meaningful if the path it shows is the path that will be acted on. The audit found the user-facing path display was the **raw model argument**: the tool-call line and approval-panel head rendered `arguments["path"]` verbatim (`executor._display_for`, `show_tool_call`), and the diff-panel title showed only the resolved *basename* (`unified_diff` used `path.name`). A model could therefore pass an argument that *displays* as one in-workspace file while `resolve_in_workspace` *acts on* another (`..` segments, `./x/../y`, symlink, trailing junk) — the user approves a write under a misleading path. The boundary itself was never weak (the action was always resolved and bounded); the gap was cosmetic, within-workspace, and it is exactly the trust an approval rests on. Closed by the **display-integrity invariant** (section 14.5): all three displays now derive from the single `workspace_display` helper, which is layered over the canonical `resolve_in_workspace` — so the displayed path is always the resolved, workspace-relative target the action uses, and a boundary-escaping path shows an honest `` marker, never a fabricated-looking path. Display-only change: the resolution and boundary checks are untouched. (`run_command` shows its literal `argv` — not a resolution case — and is unchanged.) diff --git a/pyproject.toml b/pyproject.toml index 71f08f4..577c524 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "shellpilot" -version = "0.9.0" +version = "0.10.0" description = "A local-first AI shell harness for your terminal, powered by Ollama." readme = "README.md" requires-python = ">=3.11" diff --git a/shellpilot/__init__.py b/shellpilot/__init__.py index 10679dc..5f08291 100644 --- a/shellpilot/__init__.py +++ b/shellpilot/__init__.py @@ -1,3 +1,3 @@ """ShellPilot: a local-first AI shell harness.""" -__version__ = "0.9.0" +__version__ = "0.10.0" diff --git a/shellpilot/cli/banner.py b/shellpilot/cli/banner.py new file mode 100644 index 0000000..53d6ad3 --- /dev/null +++ b/shellpilot/cli/banner.py @@ -0,0 +1,263 @@ +"""Boot-banner renderer for ShellPilot. + +Public API: + render_banner(model, *, is_cloud, profile, skills=(), recent_sessions=()) -> Panel +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from rich.align import Align +from rich.box import ROUNDED, Box +from rich.console import Group, RenderableType +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from shellpilot import __version__ +from shellpilot.cli.render import _sanitize_line + +# --------------------------------------------------------------------------- +# Jet art — locked v2 block art, embedded verbatim (do NOT read from disk). +# Each row is 28 chars wide; trailing spaces are significant. +# --------------------------------------------------------------------------- + +_JET_BLOCKS = ( + " ██ ", + " ▄██▄ ", + " ████ ", + " ████ ", + " ▄████▄ ", + " ████████ ", + " ████████ ", + " ██████████ ", + " ▄██████████▄ ", + " ▄████████████████▄ ", + " ▄████████████████████▄ ", + " ▄████████████████████████▄ ", + "████████████████████████████", + " ▀▀██████████████████████▀▀ ", + " ▀▀██████████████▀▀ ", + " ▄██████████████▄ ", + " ██████▀ ▀▀ ▀██████ ", + " ▀▀██▀▀ ▀▀██▀▀ ", +) + +# Parallel class grid: '.' = empty, 'b' = body, 'c' = cockpit. +_JET_CELLS = ( + ".............bb.............", + "............bbbb............", + "............bccb............", + "............bccb............", + "...........bbccbb...........", + "..........bbbbbbbb..........", + "..........bbbbbbbb..........", + ".........bbbbbbbbbb.........", + "........bbbbbbbbbbbb........", + ".....bbbbbbbbbbbbbbbbbb.....", + "...bbbbbbbbbbbbbbbbbbbbbb...", + ".bbbbbbbbbbbbbbbbbbbbbbbbbb.", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ".bbbbbbbbbbbbbbbbbbbbbbbbbb.", + ".....bbbbbbbbbbbbbbbbbb.....", + "......bbbbbbbbbbbbbbbb......", + ".....bbbbbbb.bb.bbbbbbb.....", + ".....bbbbbb......bbbbbb.....", +) + +_JET_WIDTH = len(_JET_BLOCKS[0]) # 28 — every row is this wide. + +# Theme color constants (aligned with shellpilot/cli/theme.py). +_COLOR_ACCENT = "#98c379" # green — local model +_COLOR_WARN = "#e5c07b" # amber — cloud model +_COLOR_CYAN = "#7fb3c8" # section headers +_COLOR_DIM = "#6b6b6b" # sp.dim — item text +_COLOR_FAINT = "#444444" # sp.faint — hints / rules +_COLOR_COCKPIT = "#080808" # near-black for cockpit cells +_GRADIENT_TOP = (0x7C, 0x7C, 0x7C) # #7c7c7c — nose +_GRADIENT_BOT = (0x3A, 0x3A, 0x3A) # #3a3a3a — tail + +# Available built-in workflow skills shown (dim) as the enable hint when none +# are enabled. These are real builtins under skills/builtin/. +_AVAILABLE_WORKFLOW_SKILLS = ("debugging", "verification", "code-review", "git-workflow") + +# Box drawing ONLY the inner vertical divider between the two columns — no +# edge, no top/bottom caps, no horizontal section rules. The third char of each +# line is the column divider (see rich.box format); everything else is blank so +# the divider spans the full content height as a clean `│`. +_DIVIDER_BOX = Box( + " \n" # top + " │ \n" # head + " \n" # head_row + " │ \n" # mid + " \n" # row + " \n" # foot_row + " │ \n" # foot + " \n" # bottom +) + + +def _lerp_hex(top: tuple[int, int, int], bot: tuple[int, int, int], t: float) -> str: + r = round(top[0] + (bot[0] - top[0]) * t) + g = round(top[1] + (bot[1] - top[1]) * t) + b = round(top[2] + (bot[2] - top[2]) * t) + return f"#{r:02x}{g:02x}{b:02x}" + + +def _build_jet() -> Text: + """The block-art jet as one fixed-width, left-aligned Text block. + + Every row is exactly ``_JET_WIDTH`` cells of real characters (empty cells + are literal spaces, never trimmed). Rendered as a left-aligned block so the + containing grid column centers the whole jet as a unit — NOT per line via + ``justify="center"``, which Rich would skew by stripping trailing spaces. + """ + num_rows = len(_JET_BLOCKS) + jet = Text(no_wrap=True, justify="left") + for row_idx, (glyph_row, cell_row) in enumerate(zip(_JET_BLOCKS, _JET_CELLS, strict=True)): + t_frac = row_idx / max(num_rows - 1, 1) + body_color = _lerp_hex(_GRADIENT_TOP, _GRADIENT_BOT, t_frac) + for glyph, cell_class in zip(glyph_row, cell_row, strict=True): + if cell_class == "c": + jet.append(glyph, style=f"bold {_COLOR_COCKPIT}") + elif cell_class == "b": + jet.append(glyph, style=body_color) + else: + jet.append(" ") + if row_idx < num_rows - 1: + jet.append("\n") + return jet + + +def _build_left_col(model: str, *, is_cloud: bool, profile: str) -> Group: + model_style = f"bold {_COLOR_WARN if is_cloud else _COLOR_ACCENT}" + locality = "cloud" if is_cloud else "local" + # The jet is centered as a fixed-width BLOCK via Align (width=_JET_WIDTH), + # not per line — per-line centering skews because Rich strips trailing + # spaces, leaving narrow rows lopsided in a live terminal. + return Group( + Text("Welcome back, pilot", style="bold", justify="center"), + Text(""), + Align.center(_build_jet(), width=_JET_WIDTH), + Text(""), + Text(model, style=model_style, justify="center"), + Text(f"{profile} · {locality}", style=_COLOR_DIM, justify="center"), + ) + + +# Right-column rule width: matches the widest section item so the horizontal +# rules stay bounded to the column rather than spanning the full panel. +_RULE_WIDTH = 40 +_ITEM_PAD = 9 # left field for the bold lead token, e.g. "/help " + + +def _section(header: str, items: Sequence[tuple[str, str]]) -> Text: + t = Text() + t.append(header, style=f"bold {_COLOR_CYAN}") + for lead, desc in items: + t.append("\n") + t.append(f"{lead:<{_ITEM_PAD}}", style="bold") + t.append(desc, style=_COLOR_DIM) + return t + + +def _rule() -> Text: + return Text("─" * _RULE_WIDTH, style=_COLOR_FAINT) + + +def _workflow_section(skills: Sequence[str]) -> Text: + t = Text() + t.append("Workflow skills", style=f"bold {_COLOR_CYAN}") + t.append("\n") + if skills: + t.append(" · ".join(skills), style=_COLOR_DIM) + else: + t.append(" · ".join(_AVAILABLE_WORKFLOW_SKILLS), style=_COLOR_DIM) + t.append("\n") + t.append("/skills to enable", style=_COLOR_FAINT) + return t + + +def _recent_section(recent_sessions: Sequence[tuple[str, str]]) -> Text: + t = Text() + t.append("Recent sessions", style=f"bold {_COLOR_CYAN}") + for label, age in recent_sessions: + t.append("\n") + t.append("● ", style=_COLOR_DIM) + # The label is a snippet of a past session's first USER message — untrusted, + # possibly-pasted input. Strip control/ANSI bytes at this render sink so a + # stored escape sequence cannot repaint the terminal on boot (Group B). + t.append(_sanitize_line(label)) + t.append(f" ({age})", style=_COLOR_DIM) + return t + + +def _build_right_col(skills: Sequence[str], recent_sessions: Sequence[tuple[str, str]]) -> Group: + blocks: list[RenderableType] = [ + _section( + "Commands", + ( + ("/help", "shortcuts & commands"), + ("/plan", "propose a plan"), + ("/skills", "enable workflow skills"), + ("/status", "session & locality"), + ), + ), + _rule(), + _section( + "Tips", + ( + ("/", "for slash commands"), + ("!", "to run a shell command"), + ('"run"', "to confirm a high-risk command"), + ), + ), + _rule(), + _workflow_section(skills), + ] + if recent_sessions: + blocks.append(_rule()) + blocks.append(_recent_section(recent_sessions)) + return Group(*blocks) + + +def render_banner( + model: str, + *, + is_cloud: bool, + profile: str, + skills: Sequence[str] = (), + recent_sessions: Sequence[tuple[str, str]] = (), +) -> Panel: + """Return a rich Panel for the boot banner. + + Args: + model: The active model name (left column; green local / amber cloud). + is_cloud: When True, the model name is styled amber and the sub-line + reads "cloud"; otherwise green and "local". + profile: Security profile name, shown in the dim sub-line. + skills: Enabled workflow-skill names. When empty, the Workflow skills + section shows the available builtins plus an enable hint. + recent_sessions: (label, age) pairs, newest first. The whole Recent + sessions section is omitted when this is empty. + + Returns: + A bounded-width (expand=False) rich Panel ready to print. + """ + grid = Table(box=_DIVIDER_BOX, show_header=False, pad_edge=False, padding=(0, 3)) + grid.add_column(justify="center", vertical="middle") + grid.add_column(justify="left", vertical="top") + grid.add_row( + _build_left_col(model, is_cloud=is_cloud, profile=profile), + _build_right_col(skills, recent_sessions), + ) + return Panel( + grid, + box=ROUNDED, + title=f"ShellPilot v{__version__}", + title_align="left", + border_style="grey50", + padding=(1, 2), + expand=False, + ) diff --git a/shellpilot/cli/input.py b/shellpilot/cli/input.py index 554dbc4..eab9d52 100644 --- a/shellpilot/cli/input.py +++ b/shellpilot/cli/input.py @@ -22,23 +22,37 @@ from rich.console import Console from shellpilot.cli.render import context_line +from shellpilot.cli.status_bar import COLOR_ACCENT, COLOR_WARN, status_bar from shellpilot.cli.theme import Glyphs PT_STYLE = Style.from_dict( { "context": "#6b6b6b", - "chevron": "#98c379 bold", + "chevron": f"{COLOR_ACCENT} bold", + "chevron.cloud": f"{COLOR_WARN} bold", + # The status bar supplies its own per-fragment colors, so clear + # prompt_toolkit's default reverse-video bottom-toolbar styling and let + # the bar render on the terminal background (instrument-minimal, §31). + "bottom-toolbar": "noreverse", + "bottom-toolbar.text": "noreverse", } ) @dataclass(frozen=True) class PromptContext: - """What the dim context line shows above the chevron.""" + """What the persistent status bar shows below the chevron. + + The locality fields (``is_cloud`` from the real ``is_egressing`` signal, + plus context utilization) feed the unspoofable active-cloud indicator and + the always-on context readout (design section 15.2 / 32). + """ workspace: Path model: str profile: str + is_cloud: bool = False + ctx_pct: int = 0 class InputProvider(Protocol): @@ -78,14 +92,19 @@ def __init__(self, history_file: Path, commands: Sequence[str], glyphs: Glyphs) ) def read(self, context: PromptContext) -> str: - ctx = context_line(context.workspace, context.model, context.profile).plain - prompt = FormattedText( - [ - ("class:context", ctx + "\n"), - ("class:chevron", f"{self._glyphs.chevron} "), - ] + # The persistent status bar (dir · model · profile · locality + ctx%) + # rides the bottom toolbar so it stays pinned and refreshes each render. + # The chevron goes amber when egressing — a second at-a-glance cloud cue. + chevron_class = "class:chevron.cloud" if context.is_cloud else "class:chevron" + prompt = FormattedText([(chevron_class, f"{self._glyphs.chevron} ")]) + bar = status_bar( + workspace=context.workspace, + model=context.model, + profile=context.profile, + is_cloud=context.is_cloud, + ctx_pct=context.ctx_pct, ) - return self._session.prompt(prompt).strip() + return self._session.prompt(prompt, bottom_toolbar=bar).strip() class PlainInput: diff --git a/shellpilot/cli/model_picker.py b/shellpilot/cli/model_picker.py index 95a2e3d..c0cda47 100644 --- a/shellpilot/cli/model_picker.py +++ b/shellpilot/cli/model_picker.py @@ -51,6 +51,21 @@ def resolve_preselect( return config_default +def confirm_last_model(console: Console, last_model: str) -> bool: + """Compact "fly the last model or open the menu" prompt. + + Returns True to use *last_model* as-is, False to open the full picker. + Empty input (Enter) / EOF / KeyboardInterrupt -> True (fly); any non-empty + input -> False (open the menu). + """ + console.print(f"[sp.emph]✈ Last flight:[/sp.emph] [sp.emph]{escape(last_model)}[/sp.emph]") + prompt = "[sp.dim]Enter to fly · any other key for the menu[/sp.dim] " + try: + return console.input(prompt).strip() == "" + except (EOFError, KeyboardInterrupt): + return True + + def choose_model(console: Console, models: list[LocalModel], preselect: str) -> str: """Print a numbered model list and prompt the user to pick one. diff --git a/shellpilot/cli/render.py b/shellpilot/cli/render.py index c4ec55b..5fe3493 100644 --- a/shellpilot/cli/render.py +++ b/shellpilot/cli/render.py @@ -12,6 +12,7 @@ from pathlib import Path from rich import box +from rich.cells import cell_len from rich.console import Group from rich.panel import Panel from rich.text import Text @@ -33,13 +34,6 @@ } -def banner(version: str, model: str, profile: str) -> Group: - return Group( - Text(f"ShellPilot {version}", style="sp.emph"), - Text(f"{model} · {profile} · /help for commands", style="sp.dim"), - ) - - def _abbreviate_home(path: Path, home: Path) -> str: try: rel = path.relative_to(home) @@ -70,8 +64,8 @@ def context_line( def tool_call(name: str, args_summary: str, glyphs: Glyphs) -> Text: return Text.assemble( (f"{glyphs.bullet} ", ""), - (name, "sp.emph"), - (f"({args_summary})", "sp.dim"), + (_sanitize_line(name), "sp.emph"), + (f"({_sanitize_line(args_summary)})", "sp.dim"), ) @@ -80,7 +74,7 @@ def tool_result(success: bool, summary: str, glyphs: Glyphs) -> Text: return Text.assemble( (f" {glyphs.elbow} ", "sp.dim"), (mark, style), - (f" {summary}", "sp.dim"), + (f" {_sanitize_line(summary)}", "sp.dim"), ) @@ -141,10 +135,19 @@ def _diff_row( base_style: str | None, word_style: str | None, ranges: list[tuple[int, int]] | None, + *, + pad_width: int = 0, ) -> Text: row = Text() row.append(f"{number:>{width}} ", style="sp.diff.gutter") prefix = len(row) + 2 # gutter + marker + space + # Pad the content under the colored background so a changed line renders as a + # full-width red/green bar (DESIGN section 31.4 full-line backgrounds): the + # removal and its addition then read as two distinct rows, never one. The + # padding rides inside base_style so the fill is colored; word ranges sit at + # the content start and are unaffected by trailing spaces. + if base_style is not None and pad_width: + content = content + " " * max(0, pad_width - cell_len(content)) row.append(f"{marker} {content}", style=base_style) if ranges and word_style: for start, end in ranges: @@ -152,10 +155,25 @@ def _diff_row( return row -def render_diff(diff_text: str, glyphs: Glyphs) -> Panel: - """Claude-Code-style diff panel: gutter, line backgrounds, word highlights.""" +def _diff_rows(diff_text: str, glyphs: Glyphs) -> tuple[list[Text], str]: + """Parse a unified diff into rendered rows plus the panel title name. + + Shared by :func:`render_diff` and the streaming ``DiffReveal`` so the reveal + animation and the settled panel never drift in how a diff is rendered. + """ lines = [_sanitize_line(line) for line in diff_text.splitlines()] width = _gutter_width(lines) + # Widest changed-line content, so every removal/addition fills its colored + # background to a uniform width (full-line red/green bars, DESIGN 31.4). + pad_width = max( + ( + cell_len(line[1:]) + for line in lines + if (line.startswith("-") and not line.startswith("---")) + or (line.startswith("+") and not line.startswith("+++")) + ), + default=0, + ) title_name = "diff" rows: list[Text] = [] old_no = new_no = 0 @@ -201,6 +219,7 @@ def render_diff(diff_text: str, glyphs: Glyphs) -> Panel: "sp.diff.remove", "sp.diff.remove_word", old_words.get(idx), + pad_width=pad_width, ) ) old_no += 1 @@ -214,11 +233,16 @@ def render_diff(diff_text: str, glyphs: Glyphs) -> Panel: "sp.diff.add", "sp.diff.add_word", new_words.get(idx), + pad_width=pad_width, ) ) new_no += 1 elif line.startswith("+"): - rows.append(_diff_row(new_no, width, "+", line[1:], "sp.diff.add", None, None)) + rows.append( + _diff_row( + new_no, width, "+", line[1:], "sp.diff.add", None, None, pad_width=pad_width + ) + ) new_no += 1 i += 1 elif line.startswith("\\") or line.startswith("... ("): @@ -230,6 +254,22 @@ def render_diff(diff_text: str, glyphs: Glyphs) -> Panel: old_no += 1 new_no += 1 i += 1 + return rows, title_name + + +def render_diff(diff_text: str, glyphs: Glyphs, *, max_rows: int | None = None) -> Panel: + """An editor-style diff panel: gutter, line backgrounds, word highlights. + + When *max_rows* is set and the rendered diff exceeds it, the panel keeps the + first ``max_rows`` rows and appends one ``… (+N more)`` footer (``sp.faint``, + mirroring :func:`output_truncation`). ``max_rows=None`` (the default at every + existing call site) renders the full diff unchanged. + """ + rows, title_name = _diff_rows(diff_text, glyphs) + if max_rows is not None and len(rows) > max_rows: + hidden = len(rows) - max_rows + rows = rows[:max_rows] + rows.append(Text(f"{glyphs.ellipsis} (+{hidden} more)", style="sp.faint")) body: Group | Text = Group(*rows) if rows else Text("(no changes)", style="sp.dim") return Panel( body, @@ -276,21 +316,22 @@ def approval_block(request: ApprovalRequest, glyphs: Glyphs, *, plain_badge: boo def plan_step_line(index: int, step: PlanStep, glyphs: Glyphs) -> Text: + title = _sanitize_line(step.title) if step.status == "completed": return Text.assemble( (f"{glyphs.check} {index}", "sp.success"), - (f" {step.title}", "sp.dim"), + (f" {title}", "sp.dim"), ) if step.status == "active": - return Text(f"{glyphs.current} {index} {step.title}", style="sp.emph") + return Text(f"{glyphs.current} {index} {title}", style="sp.emph") if step.status == "skipped": - return Text(f"{glyphs.skip} {index} {step.title}", style="sp.dim") - return Text(f"{glyphs.todo} {index} {step.title}", style="sp.dim") + return Text(f"{glyphs.skip} {index} {title}", style="sp.dim") + return Text(f"{glyphs.todo} {index} {title}", style="sp.dim") def plan_panel(plan: TaskPlan, glyphs: Glyphs) -> Panel: rows: list[Text] = [ - Text.assemble(("Goal: ", "sp.dim"), (plan.goal, "")), + Text.assemble(("Goal: ", "sp.dim"), (_sanitize_line(plan.goal), "")), Text(""), ] rows.extend(plan_step_line(i, step, glyphs) for i, step in enumerate(plan.steps, 1)) @@ -305,17 +346,5 @@ def plan_panel(plan: TaskPlan, glyphs: Glyphs) -> Panel: ) -def _format_tokens(tokens: int) -> str: - return f"{tokens / 1000:.1f}k" if tokens >= 1000 else str(tokens) - - -def turn_stats(elapsed_s: float, tokens: int, ctx_pct: int, *, warn: bool) -> Text: - stats = f" {elapsed_s:.1f}s · {_format_tokens(tokens)} tokens · " - return Text.assemble( - (stats, "sp.faint"), - (f"ctx {ctx_pct}%", "sp.warn" if warn else "sp.faint"), - ) - - def output_truncation(hidden_lines: int, glyphs: Glyphs) -> Text: return Text(f" {glyphs.ellipsis} +{hidden_lines} lines", style="sp.faint") diff --git a/shellpilot/cli/slash.py b/shellpilot/cli/slash.py index 4e89d1c..b3954ba 100644 --- a/shellpilot/cli/slash.py +++ b/shellpilot/cli/slash.py @@ -3,17 +3,31 @@ from __future__ import annotations import base64 +import os from collections.abc import Callable from enum import Enum from pathlib import Path from rich.console import Console from rich.table import Table +from rich.text import Text from shellpilot.cli.attachments import AttachmentError, AttachmentQueue, load_image from shellpilot.cli.render import plan_panel, render_diff from shellpilot.cli.theme import UNICODE_GLYPHS, Glyphs -from shellpilot.config.loader import BOOT_ONLY_KEYS, ConfigError, LoadedConfig, validate_override +from shellpilot.config.loader import ( + BOOT_ONLY_KEYS, + HIGH_STAKES_KEYS, + ConfigError, + LoadedConfig, + validate_override, +) +from shellpilot.config.model import ( + TESTED_FAMILIES, + is_cloud_model, + is_egressing, + is_tested_model, +) from shellpilot.config.overrides import load_overrides, overrides_path, save_overrides from shellpilot.llm.client import LLMClient from shellpilot.runtime.conversation import ConversationRuntime @@ -28,7 +42,7 @@ class SlashAction(Enum): HELP_ROWS: list[tuple[str, str]] = [ ("/help", "Show available commands."), - ("/exit, /quit", "Exit ShellPilot."), + ("/exit", "Exit ShellPilot."), ("/clear", "Clear the visible conversation after confirmation."), ("/status", "Show model, profile, workspace, and context usage."), ("/model", "Show the active model and context metadata."), @@ -58,12 +72,10 @@ class SlashAction(Enum): ("/logs", "Show recent audit events for this session."), ("/logs all", "Show recent audit events across all sessions."), ("/export ", "Export this session's transcript to markdown."), - ("/memory show", "Show stored preferences and project facts with ids."), + ("/memory show", "Show preferences (scope/source) and facts with ids, plus file paths."), ("/memory add ", "Add a global behavior preference after confirmation."), ("/memory forget ", "Remove a memory entry after confirmation."), ("/memory compact", "Model-assisted preference cleanup, approved before saving."), - ("/prefs show", "Show behavior preferences."), - ("/prefs edit", "Show the memory file paths for hand-editing."), ("/shell", "Enter Manual Shell mode (raw shell, user-typed)."), ("/attach ", "Stage an image to send with your next message (vision models only)."), ("/attach", "List currently staged images."), @@ -98,6 +110,60 @@ def _default_confirm(prompt: str) -> bool: return input(f"{prompt} [y/N] ").strip().lower() in ("y", "yes") +# Per-key risk phrasing for the HIGH_STAKES_KEYS confirm-gate in /config set. +_HIGH_STAKES_RISK: dict[str, str] = { + "model.allow_cloud": "enables cloud egress — model calls may leave the device", + "tools.web": "enables web egress — searches and fetches leave the device", + "model.base_url": "changes the model endpoint — requests go to a different host", + "runtime.security_profile": "lowers the local safety profile — low-risk commands may auto-run", +} + +# Starter config written by `/config edit` ONLY when no config.toml exists yet. +# Every key is commented out, so the file is valid TOML that load_config accepts +# and that resolves to the built-in defaults — i.e. an effectively empty config. +# It is never written over an existing file (config.toml is user-owned; the +# program never rewrites it). Keep it concise: common keys plus the egress/safety +# keys, with honest notes on which are config-file-only vs high-stakes. +_STARTER_CONFIG = """\ +# ShellPilot config — starter template (every key is commented out, so it +# resolves to the built-in defaults). Uncomment and edit the keys you want. +# ShellPilot never rewrites this file; it is yours to edit by hand. +# +# Boot-only keys (model.*, ui.*, instructions.*, tools.web) take effect next +# session. Runtime-settable keys can also be changed live with /config set. +# Egress/safety keys (tools.web, model.base_url, model.allow_cloud, +# runtime.security_profile) are settable here OR via a confirm-gated /config set, +# but NEVER via an environment variable. The structural keys model.options and +# skills.enabled are config-file-only: edit them here, never via /config set. + +[model] +# default = "gemma4:e4b" +# keep_alive = "5m" # how long Ollama keeps the model warm +# base_url = "http://localhost:11434" # high-stakes: changes the endpoint +# allow_cloud = false # high-stakes: master cloud-egress switch + +# [model.options] # config-file-only: verbatim Ollama options +# repeat_penalty = 1.3 # num_ctx is reserved and ignored here + +[runtime] +# security_profile = "balanced" # high-stakes: "balanced" | "supervised" +# auto_compact = true + +[tools] +# web = false # high-stakes: registers web_search + web_fetch + +[skills] +# enabled = ["my-skill"] # config-file-only: skill folders to activate + +[privacy] +# allow_sensitive_reads = "ask" # ask | never | always + +[ui] +# theme = "default" +# glyphs = "auto" # auto | unicode | ascii +""" + + class SlashDispatcher: """Parses and executes slash commands against the running session.""" @@ -114,6 +180,7 @@ def __init__( glyphs: Glyphs = UNICODE_GLYPHS, preload: Callable[[str], None] | None = None, attachments: AttachmentQueue | None = None, + tty: bool = True, ) -> None: self._runtime = runtime self._client = client @@ -125,12 +192,13 @@ def __init__( self._glyphs = glyphs self._preload = preload self._attachments = attachments + self._tty = tty def handle(self, line: str) -> SlashAction: parts = line.strip().split() command, args = parts[0].lower(), parts[1:] - if command in ("/exit", "/quit"): + if command == "/exit": return SlashAction.EXIT if command == "/shell": return SlashAction.MANUAL_SHELL @@ -166,8 +234,6 @@ def handle(self, line: str) -> SlashAction: self._export(args) elif command == "/memory": self._memory(args) - elif command == "/prefs": - self._prefs(args) elif command == "/attach": self._attach(args) elif command == "/skills": @@ -197,6 +263,7 @@ def _status(self) -> None: status = self._runtime.status() self._console.print(f"Model: {status.model}") self._console.print(f"Profile: {status.profile}") + self._console.print(self._locality_line(status.model)) self._console.print(f"Workspace: {status.workspace}") self._console.print( f"Context: ~{status.estimated_prompt_tokens} of " @@ -210,6 +277,26 @@ def _status(self) -> None: self._console.print("Active plan: none") self._console.print("Pending approvals: none") + def _locality_line(self, model: str) -> Text: + """Honest one-line locality readout derived from the egress signal. + + REMOTE in amber names the off-box host (the configured non-loopback + endpoint, or 'cloud' for a -cloud model proxied through loopback); + local in dim otherwise (design section 15.2). + """ + from urllib.parse import urlsplit + + from shellpilot.llm.ollama import is_loopback_url + + base_url = self._loaded.settings.model.base_url + if not is_egressing(model, base_url): + return Text("Locality: local", style="sp.dim") + if not is_loopback_url(base_url): + host = (urlsplit(base_url).hostname or "").rstrip(".") or base_url + else: + host = "cloud" if is_cloud_model(model) else base_url + return Text(f"Locality: REMOTE — {host}", style="sp.warn") + def _model(self, args: list[str]) -> None: if not args: status = self._runtime.status() @@ -219,8 +306,6 @@ def _model(self, args: list[str]) -> None: ) return if args[0] == "list": - from shellpilot.config.model import TESTED_FAMILIES, is_tested_model - models = self._client.list_models() table = Table(title="Local models") table.add_column("Name") @@ -240,14 +325,28 @@ def _model(self, args: list[str]) -> None: ) return if args[0] == "use" and len(args) > 1: - from shellpilot.config.model import TESTED_FAMILIES, is_tested_model from shellpilot.persistence.workspace_state import save_last_model name = args[1] installed = {model.name for model in self._client.list_models()} - if name not in installed: + # Cloud models are absent from /api/tags — skip the availability gate + # for them (the typo-catch survives for local names). + if name not in installed and not is_cloud_model(name): self._console.print(f"[red]{name} is not installed.[/red] See /model list.") return + # Cloud-egress consent boundary (design section 15.2): switching to a + # cloud/remote model mid-session requires allow_cloud + per-session + # consent BEFORE set_model/_preload touch it. On reject: no switch, + # no preload, no egress. + from shellpilot.cli.terminal import _resolve_cloud_consent + from shellpilot.config.model import is_egressing + + base_url = self._loaded.settings.model.base_url + egressing = is_egressing(name, base_url) + if not _resolve_cloud_consent( + self._console, self._loaded.settings, name, tty=self._tty + ): + return self._runtime.set_model(name) workspace = self._runtime.status().workspace try: @@ -256,6 +355,14 @@ def _model(self, args: list[str]) -> None: self._console.print(f"[dim]Warning: could not save model choice: {exc}[/dim]") if self._preload is not None: self._preload(name) + if egressing and self._runtime.audit is not None: + from urllib.parse import urlsplit + + self._runtime.audit.write( + "cloud_consent_granted", + model=name, + host=(urlsplit(base_url).hostname or "").rstrip("."), + ) self._console.print(f"Switched to {name}.") if not is_tested_model(name): families = ", ".join(TESTED_FAMILIES) @@ -272,12 +379,7 @@ def _config(self, args: list[str]) -> None: # noqa: C901 - intentionally one ha if action == "show": render_config(self._loaded, self._console) elif action == "edit": - self._console.print(f"User config: {self._user_config_file}") - self._console.print("Edit the file, then run /config reload.") - self._console.print( - "[dim]Tip: use /config set " - "to make persistent changes in-program.[/dim]" - ) + self._config_edit() elif action == "reload": try: self._loaded = self._reload_config() @@ -289,7 +391,7 @@ def _config(self, args: list[str]) -> None: # noqa: C901 - intentionally one ha self._print_config_warnings() elif action == "set": self._config_set(args[1:]) - elif action in ("unset", "reset") and len(args) > 1: + elif action == "unset" and len(args) > 1: self._config_unset(args[1]) elif action == "reset" and len(args) == 1: self._config_reset_all() @@ -298,9 +400,33 @@ def _config(self, args: list[str]) -> None: # noqa: C901 - intentionally one ha "Usage: /config show | /config edit | /config reload" " | /config set " " | /config unset " - " | /config reset []" + " | /config reset" ) + def _config_edit(self) -> None: + path = self._user_config_file + created = False + if not path.exists(): + # No user config yet: write a commented starter so the printed path + # is real and editable. NEVER touch an existing config.toml — it is + # user-owned and the program never rewrites it. + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(_STARTER_CONFIG) + created = True + self._console.print(f"User config: {path}") + if created: + self._console.print( + "[dim]Created a starter config (every key commented out, so it " + "resolves to defaults). Uncomment and edit the keys you want.[/dim]" + ) + self._console.print("Edit the file, then run /config reload.") + self._console.print( + "[dim]Boot-only keys take effect next session; runtime-settable keys " + "can also use /config set .[/dim]" + ) + def _overrides_path(self) -> Path: return overrides_path(self._user_config_file.parent) @@ -326,6 +452,31 @@ def _config_set(self, args: list[str]) -> None: except ConfigError as exc: self._console.print(f"[red]Config error:[/red] {exc}") return + # Egress / safety keys: amber warning + explicit confirm before persisting. + if key in HIGH_STAKES_KEYS: + risk = _HIGH_STAKES_RISK[key] + self._console.print(Text(f"⚠ {key} {risk}.", style="sp.warn")) + # security_profile is read per turn (conversation.py), so a set + # applies this session; the egress keys are boot-only. Never defer a + # live safety downgrade to "next session". + when = "next session" if key in BOOT_ONLY_KEYS else "this session (next turn)" + self._console.print( + Text( + f"It takes effect {when} and persists in overrides.json " + f"until /config unset {key}.", + style="sp.warn", + ) + ) + if key == "runtime.security_profile": + self._console.print( + Text( + "Use /profile use for an unsaved, session-only change.", + style="sp.warn", + ) + ) + if not self._confirm(f"Persist {key} = {coerced!r} to overrides?"): + self._console.print("[dim]unchanged.[/dim]") + return # Capture old effective value before overwrite. old_value = self._resolve_setting_value(self._loaded, key) # Read → mutate → save (atomic). @@ -342,7 +493,8 @@ def _config_set(self, args: list[str]) -> None: self._runtime.update_settings(self._loaded.settings) new_value = self._resolve_setting_value(self._loaded, key) self._console.print(f"{key}: {old_value!r} → {new_value!r}") - if key in BOOT_ONLY_KEYS: + # High-stakes keys already printed their own when/persist note above. + if key in BOOT_ONLY_KEYS and key not in HIGH_STAKES_KEYS: note = "(saved — takes effect next session)" if key == "model.default": note += " — use /model use to switch now" @@ -527,7 +679,7 @@ def _memory(self, args: list[str]) -> None: if action == "show": stores.global_store.reload() stores.project_store.reload() - block = stores.render(max_tokens=4000) + block = stores.render(max_tokens=4000, meta=True) if block: self._console.print(block, markup=False, highlight=False) else: @@ -657,34 +809,6 @@ def _memory_compact(self, stores: object) -> None: self._audit_memory(f"compact {len(all_preferences)} -> {kept}") self._console.print(f"Memory compacted: {len(all_preferences)} -> {kept} preferences.") - def _prefs(self, args: list[str]) -> None: - stores = self._runtime.memory - if stores is None: - self._console.print("[dim]Memory is not available this session.[/dim]") - return - action = args[0] if args else "show" - if action == "show": - preferences = list(stores.global_store.preferences) + list( - stores.project_store.preferences - ) - if not preferences: - self._console.print("[dim]No behavior preferences stored.[/dim]") - return - for preference in preferences: - self._console.print( - f"[{preference.id}] ({preference.scope}, {preference.source}) " - f"{preference.text}", - markup=False, - highlight=False, - ) - return - if action == "edit": - self._console.print(f"Global memory: {stores.global_store.path}") - self._console.print(f"Project memory: {stores.project_store.path}") - self._console.print("Edit by hand, then run /memory show to reload.") - return - self._console.print("Usage: /prefs show | /prefs edit") - def _tools(self) -> None: profile = self._runtime.settings.runtime.security_profile table = Table(title=f"Tools (profile: {profile})") diff --git a/shellpilot/cli/status_bar.py b/shellpilot/cli/status_bar.py new file mode 100644 index 0000000..b1e145f --- /dev/null +++ b/shellpilot/cli/status_bar.py @@ -0,0 +1,124 @@ +"""Persistent bottom status bar — the at-a-glance dock above the input. + +An editor-style one-line bar pinned just above the prompt (rendered as the +input's ``bottom_toolbar``, so it stays static and refreshes each render). It +folds two previously-separate readouts into one always-on place: + + - the unspoofable active-cloud indicator (design section 15.2), and + - context utilization, formerly printed after every reply. + +Layout (prototype "V1", design section 32): + LEFT · · · + RIGHT % ctx + +``status_bar`` is a PURE builder mirroring ``cli/banner.py``: it returns a +prompt_toolkit ``FormattedText`` list and performs no I/O. dir/model/profile/ +ctx come from runtime/session state — never from the model — and the locality +segment is gated on the caller's real ``is_egressing`` signal, keeping the +cloud indicator harness-rendered and unspoofable. The workspace path is +user-controlled, so it is stripped of control/ANSI bytes before render. +""" + +from __future__ import annotations + +from pathlib import Path + +from prompt_toolkit.formatted_text import FormattedText + +from shellpilot.cli.render import _abbreviate_home, _sanitize_line + +# Theme color constants (hex strings; aligned with shellpilot/cli/theme.py). +# prompt_toolkit cannot read the rich Theme, so the bar mirrors the same hexes, +# the same pattern cli/banner.py uses for its rich-independent jet art. +COLOR_ACCENT = "#98c379" # green — local model / low ctx +COLOR_WARN = "#e5c07b" # amber — cloud model / mid ctx / egress emphasis +COLOR_ERROR = "#e06c75" # red — high ctx +COLOR_DIM = "#6b6b6b" # sp.dim — ordinary segment text +COLOR_FAINT = "#444444" # sp.faint — separators + +# Context-utilization color thresholds (percent full): green below MID, amber +# from MID up to HI, red at/above HI. +CTX_MID = 50 +CTX_HI = 80 + + +def ctx_percent(used_tokens: int, total_tokens: int) -> int: + """Context utilization as an integer percent, rounded and clamped to 0–100. + + Mirrors the runtime's own post-turn formula so the always-on bar and the + (now removed) per-turn line never disagree; a zero budget never divides. + """ + if total_tokens <= 0: + return 0 + return min(100, round(100 * used_tokens / total_tokens)) + + +def _ctx_color(pct: int) -> str: + if pct >= CTX_HI: + return COLOR_ERROR + if pct >= CTX_MID: + return COLOR_WARN + return COLOR_ACCENT + + +def status_bar( + *, + workspace: Path, + model: str, + profile: str, + is_cloud: bool, + ctx_pct: int, + home: Path | None = None, +) -> FormattedText: + """Build the persistent status-bar fragments for the input bottom toolbar. + + Args: + workspace: Active workspace path (shown home-abbreviated, sanitized). + model: Active model name — GREEN when local, AMBER when egressing. + profile: Security profile name (e.g. ``balanced``). + is_cloud: The caller's REAL ``is_egressing`` signal. When True the bar + carries an unmistakable amber emphasis and a ``☁ CLOUD`` + locality; this is the harness-rendered active-cloud indicator + (design section 15.2) — never derived from model output. + ctx_pct: Context utilization percent (see ``ctx_percent``); color- + coded green → amber → red as it fills. + home: Home directory for abbreviation (defaults to ``Path.home()``). + + Returns: + A prompt_toolkit ``FormattedText`` (list of ``(style, text)`` fragments). + """ + # Sanitize the user-controlled path before it reaches the terminal (Group B): + # strip control/ANSI bytes so a crafted directory name cannot repaint the UI. + dir_text = _sanitize_line(_abbreviate_home(workspace, home or Path.home())) + model_text = _sanitize_line(model) + profile_text = _sanitize_line(profile) + + # When egressing, separators carry a faint amber tint — the "wash" adapted to + # a terminal — so the whole bar reads as cloud at a glance, distinct from a + # local (faint-grey) bar. Locality and model also go amber below. + sep_color = COLOR_WARN if is_cloud else COLOR_FAINT + model_color = COLOR_WARN if is_cloud else COLOR_ACCENT + sep = (f"fg:{sep_color}", " · ") + + if is_cloud: + locality = (f"fg:{COLOR_WARN} bold", "☁ CLOUD") + else: + locality = (f"fg:{COLOR_ACCENT}", "● local") + + left: list[tuple[str, str]] = [ + (f"fg:{COLOR_DIM}", dir_text), + sep, + (f"fg:{model_color}", model_text), + sep, + (f"fg:{COLOR_DIM}", profile_text), + sep, + locality, + ] + + right: list[tuple[str, str]] = [ + (f"fg:{_ctx_color(ctx_pct)}", f"{ctx_pct}%"), + (f"fg:{COLOR_DIM}", " ctx"), + ] + + gap = (f"fg:{sep_color}", " ") + return FormattedText([*left, gap, *right]) diff --git a/shellpilot/cli/streaming.py b/shellpilot/cli/streaming.py index 4ae8f5a..da553ea 100644 --- a/shellpilot/cli/streaming.py +++ b/shellpilot/cli/streaming.py @@ -10,16 +10,18 @@ from __future__ import annotations +import math import random import threading import time -from typing import NamedTuple +from typing import ClassVar, NamedTuple -from rich.console import Console +from rich.console import Console, Group from rich.live import Live from rich.markdown import Markdown from rich.text import Text +from shellpilot.cli.render import _diff_rows, _sanitize_line from shellpilot.cli.theme import Glyphs _PHRASE_SECONDS = 10 @@ -126,10 +128,11 @@ def __init__(self, console: Console) -> None: def _tail_markdown(self) -> Markdown: max_lines = max(4, self._console.size.height - 4) tail = "\n".join(self._buffer.splitlines()[-max_lines:]) - return Markdown(tail) + return Markdown(_sanitize_line(tail)) def feed(self, token: str) -> None: if not self._console.is_terminal: + token = _sanitize_line(token) self._console.print(token, end="", markup=False, highlight=False, soft_wrap=True) self._buffer += token return @@ -165,10 +168,91 @@ def finish(self) -> None: self._live.stop() self._live = None if self._buffer: - self._console.print(Markdown(self._buffer)) + self._console.print(Markdown(_sanitize_line(self._buffer))) self._buffer = "" +class DiffReveal: + """Transient scrolling reveal for a long approval diff (design section 31.4). + + Synchronous and main-thread (unlike :class:`AviationSpinner`): the approval + prompt is already waiting on user input, so blocking through a brief reveal + is safe and avoids a second background Live. ``reveal()`` is a no-op when + motion is disabled, on a non-terminal, or for a short diff — the caller then + just prints the settled panel. + + LIVE-ORDERING (load-bearing): two concurrent ``rich.live.Live`` on one + Console corrupt the display. The caller (``TerminalUI.ask_approval``) MUST + stop the spinner — ``self._spinner.stop()`` joins its thread and stops its + Live before returning — strictly BEFORE invoking ``reveal()``, so this Live + only ever opens after the spinner's is fully gone. + """ + + # A diff shorter than this never animates (a 3-line patch ≈ 6 rows). Constant, + # not console height: height is unknown/0 in tests and this is a UX heuristic. + ANIMATE_THRESHOLD: ClassVar[int] = 20 + # Rows visible in the reveal window AND the settled-panel cap. Same value so + # the last reveal frame equals the settled panel; the footer reports the rest. + WINDOW_ROWS: ClassVar[int] = 24 + # Total reveal time, bounded regardless of length: a huge diff adds no real + # latency (chunk size scales with row count to keep within this budget). + TOTAL_DURATION: ClassVar[float] = 0.6 + # NOTE: tools/patch.py unified_diff already caps the source at + # MAX_PREVIEW_LINES = 60, so request.diff is <=~60 lines and these row-level + # bounds are a second display layer — no need to re-cap beyond WINDOW_ROWS. + + def __init__(self, console: Console, glyphs: Glyphs, *, enabled: bool) -> None: + self._console = console + self._glyphs = glyphs + self._enabled = enabled and console.is_terminal + + def row_count(self, diff_text: str) -> int: + """Number of rendered diff rows — pure, cheap, no Panel built.""" + rows, _ = _diff_rows(diff_text, self._glyphs) + return len(rows) + + def reveal(self, diff_text: str, *, max_rows: int) -> None: + """Animate a scrolling reveal of *diff_text*, then return. + + No-op when motion is off, on a non-terminal, or for a diff at or below + ``ANIMATE_THRESHOLD`` rows. The caller prints the settled (capped) panel + next regardless — capping is a display choice, not part of the motion. + """ + rows, _ = _diff_rows(diff_text, self._glyphs) + if not self._enabled or len(rows) <= self.ANIMATE_THRESHOLD: + return + total = len(rows) + ticks = max(1, math.floor(self.TOTAL_DURATION / _REFRESH_SECONDS)) + chunk = max(1, math.ceil(total / ticks)) + live = Live( + self._frame(rows, chunk, max_rows), + console=self._console, + transient=True, + auto_refresh=False, + vertical_overflow="crop", + ) + live.start() + try: + revealed = chunk + while revealed < total: + time.sleep(_REFRESH_SECONDS) + revealed += chunk + live.update(self._frame(rows, revealed, max_rows), refresh=True) + finally: + # Clear before stop so Live.stop()'s forced vertical_overflow="visible" + # repaint renders nothing — same clear-before-stop as + # ResponseStream.finish(), preventing a tall reveal from leaking into + # scrollback and double-rendering under the settled panel. + live.update("", refresh=False) + live.stop() + + def _frame(self, rows: list[Text], revealed: int, max_rows: int) -> Group: + """A reveal frame: the last ``max_rows`` of the rows revealed so far.""" + shown = rows[: min(revealed, len(rows))] + window = shown[-max_rows:] + return Group(*window) + + class AviationSpinner: """Accent-colored flight-phase status while the model works; erases itself cleanly. diff --git a/shellpilot/cli/terminal.py b/shellpilot/cli/terminal.py index 1999266..24f534d 100644 --- a/shellpilot/cli/terminal.py +++ b/shellpilot/cli/terminal.py @@ -7,21 +7,27 @@ import time import uuid from pathlib import Path +from urllib.parse import urlsplit from rich.console import Console from rich.markup import escape from rich.padding import Padding from rich.text import Text -from shellpilot import __version__ from shellpilot.cli.attachments import AttachmentError, AttachmentQueue, load_image +from shellpilot.cli.banner import render_banner from shellpilot.cli.input import PromptContext, make_input -from shellpilot.cli.manual_shell import manual_shell_loop -from shellpilot.cli.model_picker import choose_model, resolve_preselect, should_show_picker +from shellpilot.cli.manual_shell import manual_shell_loop, run_manual_command +from shellpilot.cli.model_picker import ( + choose_model, + confirm_last_model, + resolve_preselect, + should_show_picker, +) from shellpilot.cli.render import ( + _sanitize_line, approval_cwd, approval_info, - banner, plan_panel, plan_step_line, render_diff, @@ -32,28 +38,37 @@ from shellpilot.cli.render import ( tool_result as render_tool_result, ) -from shellpilot.cli.render import ( - turn_stats as render_turn_stats, -) from shellpilot.cli.slash import SlashAction, SlashDispatcher, command_words -from shellpilot.cli.streaming import AviationSpinner, ResponseStream +from shellpilot.cli.status_bar import ctx_percent +from shellpilot.cli.streaming import AviationSpinner, DiffReveal, ResponseStream from shellpilot.cli.theme import UNICODE_GLYPHS, Glyphs, build_console, resolve_glyphs -from shellpilot.config.loader import ConfigError, LoadedConfig, load_config -from shellpilot.config.model import Settings +from shellpilot.config.loader import HIGH_STAKES_KEYS, ConfigError, LoadedConfig, load_config +from shellpilot.config.model import Settings, is_cloud_model, is_egressing +from shellpilot.config.overrides import load_overrides, overrides_path from shellpilot.llm.ollama import OllamaClient, OllamaError -from shellpilot.memory.agents_md import BehaviorInstructions, load_behavior_instructions +from shellpilot.memory.agents_md import ( + BehaviorInstructions, + load_behavior_instructions, + project_agents_md_digest, +) from shellpilot.memory.redaction import redact_structure from shellpilot.memory.store import MemoryFormatError, MemoryStore, MemoryStores, project_id_for from shellpilot.persistence.audit_store import AuditLogger from shellpilot.persistence.paths import AppPaths, project_state_dir from shellpilot.persistence.sessions import SessionStore -from shellpilot.persistence.workspace_state import load_last_model, save_last_model -from shellpilot.policy.approvals import ApprovalRequest +from shellpilot.persistence.workspace_state import ( + load_last_model, + load_trusted_agents_digest, + save_last_model, + save_trusted_agents_digest, +) +from shellpilot.policy.approvals import ApprovalReply, ApprovalRequest from shellpilot.policy.risk import RiskLevel from shellpilot.runtime.conversation import ConversationRuntime from shellpilot.runtime.events import TurnStats from shellpilot.runtime.planner import TaskPlan from shellpilot.skills.loader import discover_skills +from shellpilot.tools.base import workspace_display def should_discard_interrupt( @@ -65,6 +80,100 @@ def should_discard_interrupt( return turn_just_ran and elapsed_seconds < window_seconds +def _resolve_project_agents_trust(console: Console, workspace: Path, *, tty: bool) -> bool: + """Trust-on-first-use gate for the project ``/AGENTS.md``. + + The project AGENTS.md is injected as standing instructions with the same + authority as ShellPilot's own prompt, so a cloned/untrusted repo could + silently steer the assistant. Load it only when its current digest matches + a previously accepted one, or after the user accepts it this session. + A non-TTY session fails closed (not loaded). Global config-dir AGENTS.md is + unaffected — it is always trusted. (Design section 16.) + """ + digest = project_agents_md_digest(workspace) + if digest is None: + return True # no project AGENTS.md to gate + trusted = load_trusted_agents_digest(workspace) + if digest == trusted: + return True + if not tty: + console.print("[sp.dim]Project AGENTS.md not loaded (non-interactive; untrusted).[/sp.dim]") + return False + note = "changed since you last trusted it" if trusted is not None else "new" + console.print( + f"[yellow]Project AGENTS.md[/yellow] at " + f"[sp.faint]{escape(str(workspace / 'AGENTS.md'))}[/sp.faint] is {note}.\n" + "[sp.dim]It would be loaded as standing instructions with the same " + "authority as ShellPilot's own prompt.[/sp.dim]" + ) + try: + answer = console.input(" Trust and load it? [sp.dim]\\[y/N][/sp.dim] ") + except (EOFError, KeyboardInterrupt): + answer = "" + if answer.strip().lower() in ("y", "yes"): + save_trusted_agents_digest(workspace, digest) + return True + console.print("[sp.dim]Project AGENTS.md not loaded (declined).[/sp.dim]") + return False + + +# The honest disclosure shown before egressing to a cloud/remote model. It must +# state plainly what leaves the device — this prompt IS the consent boundary +# (design section 15.2). Best-effort redaction is defence-in-depth, not a +# guarantee, so the text does not promise protection. +CLOUD_CONSENT_DISCLOSURE = ( + "[yellow]{model}[/yellow] is a cloud/remote model: it runs OFF this device.\n" + "[sp.dim]The ENTIRE prompt — file contents, command output, and any memory the " + "model reads — is sent to the provider and may be UNREDACTED. Under the balanced " + "profile, low-risk actions auto-run and can send data without a per-action prompt. " + "The provider's retention, training, and jurisdiction are outside ShellPilot's " + "control.[/sp.dim]" +) + + +def _resolve_cloud_consent(console: Console, settings: Settings, chosen: str, *, tty: bool) -> bool: + """Per-session consent gate for a cloud/remote (egressing) model. + + The consent boundary for data leaving the device (design section 15.2). + Returns True to proceed, False to abort — the caller MUST NOT touch the + model (no preload, no metadata, no chat) when this returns False. + + Fails closed on every uncertainty: + - A non-egressing local session proceeds with NO prompt (the common path). + - An egressing session with ``allow_cloud`` off is refused with a clear + message pointing at the config switch. + - An egressing session in a non-interactive (non-TTY) context is refused + — there is no way to obtain consent, so nothing egresses. + - Otherwise the user is shown an honest disclosure and a y/N prompt that + DEFAULTS TO NO; only an explicit yes proceeds (Enter/EOF/no decline). + + Consent is per session — never persisted; every launch re-asks. + """ + egressing = is_egressing(chosen, settings.model.base_url) + if not egressing: + return True + if not settings.model.allow_cloud: + console.print( + f"[red]{escape(chosen)} is a cloud/remote model; cloud egress is off.[/red] " + "Set [model] allow_cloud = true in config.toml to enable." + ) + return False + if not tty: + console.print( + "[red]Cloud model requires interactive consent; refusing (non-interactive).[/red]" + ) + return False + console.print(CLOUD_CONSENT_DISCLOSURE.format(model=escape(chosen))) + try: + answer = console.input(" Send this session to the cloud? [sp.dim]\\[y/N][/sp.dim] ") + except (EOFError, KeyboardInterrupt): + answer = "" + if answer.strip().lower() in ("y", "yes"): + return True + console.print("[sp.dim]Cloud model declined; not started.[/sp.dim]") + return False + + class TerminalUI: """RuntimeUI implementation over a rich console.""" @@ -74,11 +183,18 @@ def __init__( *, glyphs: Glyphs = UNICODE_GLYPHS, spinner: bool = True, + workspace: Path | None = None, ) -> None: self._console = console self._glyphs = glyphs + # Workspace for display-integrity (design section 14.5): when set, a + # `path` argument in the tool-call line is shown as its resolved, + # workspace-relative target — the SAME resolution the tool acts on. + self._workspace = workspace self._stream = ResponseStream(console) self._spinner = AviationSpinner(console, glyphs, enabled=spinner) + # The diff-reveal animation rides the same motion toggle as the spinner. + self._diff_reveal = DiffReveal(console, glyphs, enabled=spinner) def begin_response(self) -> None: self._spinner.start() @@ -88,44 +204,57 @@ def end_response(self) -> None: self._stream.finish() def turn_finished(self, stats: TurnStats) -> None: - self._console.print( - render_turn_stats( - stats.elapsed_s, stats.context_tokens, stats.context_pct, warn=stats.warn - ) - ) + # Context utilization now lives in the always-on bottom status bar + # (design section 32), so the turn no longer prints a per-response line. + # The method stays on the RuntimeUI protocol for the runtime to call. + return def stream_token(self, token: str) -> None: self._spinner.stop() self._stream.feed(token) def show_status(self, text: str) -> None: - self._console.print(f"[sp.dim]{escape(text)}[/sp.dim]") + self._console.print(f"[sp.dim]{escape(_sanitize_line(text))}[/sp.dim]") def show_error(self, text: str) -> None: self._spinner.stop() - self._console.print(f"[sp.error]{escape(text)}[/sp.error]") + self._console.print(f"[sp.error]{escape(_sanitize_line(text))}[/sp.error]") def show_tool_call(self, name: str, arguments: dict[str, object]) -> None: # Redact secrets in the summary line so auto-approved tool calls never - # expose credentials in the visible terminal channel. The approval - # panel (ApprovalRequest.display built by executor._display_for) is - # intentionally left raw: the user approves exactly what will execute. + # expose credentials in the visible terminal channel. A `path` argument + # is shown as its resolved, workspace-relative target (the SAME + # resolution the tool acts on) so the displayed path cannot be spoofed + # and matches the file actually touched (design section 14.5); the + # approval panel applies the identical rule via executor._display_for. redacted = redact_structure(arguments) assert isinstance(redacted, dict) - summary = ", ".join(f"{key}={value!r}" for key, value in redacted.items()) + summary = ", ".join( + f"{key}={self._tool_call_value(key, value)}" for key, value in redacted.items() + ) if len(summary) > 80: summary = summary[:79] + self._glyphs.ellipsis self._console.print(render_tool_call(name, summary, self._glyphs)) - label = Text.assemble(("running ", "sp.dim"), (name, "sp.emph")) + label = Text.assemble(("running ", "sp.dim"), (_sanitize_line(name), "sp.emph")) self._spinner.start(label=label) + def _tool_call_value(self, key: str, value: object) -> str: + # A `path` argument is shown as its resolved, workspace-relative target + # (display-integrity, design section 14.5). Without a workspace (legacy + # callers) the value renders verbatim. + if key == "path" and isinstance(value, str) and self._workspace is not None: + return repr(workspace_display(self._workspace, value)) + return repr(value) + def show_tool_result(self, name: str, success: bool, summary: str) -> None: self._spinner.stop() self._console.print(render_tool_result(success, summary, self._glyphs)) def show_command_output(self, line: str) -> None: self._spinner.stop() - self._console.print(" " + line, style="sp.dim", markup=False, highlight=False) + self._console.print( + " " + _sanitize_line(line), style="sp.dim", markup=False, highlight=False + ) def show_plan_progress(self, plan: TaskPlan) -> None: self._spinner.stop() @@ -136,32 +265,70 @@ def show_plan_progress(self, plan: TaskPlan) -> None: def _plain_badges(self) -> bool: return self._console.no_color or not self._console.is_terminal - def ask_approval(self, request: ApprovalRequest) -> bool: + def ask_approval(self, request: ApprovalRequest) -> ApprovalReply: """Badge-block approval (section 31.5); high risk requires typing 'run'. + Three-way outcome (section 14.6): [y]es runs, [n]o declines, [e]dit + rejects-and-steers — the proposed action is NOT run; the user types + guidance that is fed back to the model, which re-proposes a corrected + action through the normal gate. Empty guidance is treated as a plain + decline (nothing runs). The HIGH-risk typed-"run" confirm is unchanged: + only the literal "run" executes; [e] steers without running. + No head line here: the tool-call line printed just before the approval already names the action, so repeating it would duplicate output. """ + # LIVE-ORDERING (load-bearing): stop the spinner FIRST. It joins its + # thread and stops its Live before returning, so DiffReveal's Live (below) + # never overlaps it — two concurrent rich Live on one Console corrupt the + # display. The reveal must start strictly after this call. self._spinner.stop() self._console.print() if request.diff: - self._console.print(Padding(render_diff(request.diff, self._glyphs), (0, 0, 0, 2))) + cap = DiffReveal.WINDOW_ROWS + long_diff = self._diff_reveal.row_count(request.diff) > DiffReveal.ANIMATE_THRESHOLD + # Long diffs scroll-reveal (motion only when enabled+TTY) then settle + # into a capped window; short diffs print the full panel unchanged. + self._diff_reveal.reveal(request.diff, max_rows=cap) + self._console.print( + Padding( + render_diff(request.diff, self._glyphs, max_rows=cap if long_diff else None), + (0, 0, 0, 2), + ) + ) self._console.print(approval_info(request, plain_badge=self._plain_badges())) self._console.print(approval_cwd(request)) try: # The typed-"run" gate guards HIGH-risk *commands* only. A HIGH-risk # tool is a sensitive-path read (design section 15): it gets the - # standard y/n prompt, with the classifier reason already shown above. + # standard prompt, with the classifier reason already shown above. if request.risk is RiskLevel.HIGH and request.kind == "command": answer = self._console.input( ' Type [sp.risk.high]"run"[/sp.risk.high] to execute, ' - "or press Enter to cancel: " - ) - return answer.strip().lower() == "run" - answer = self._console.input(" Approve? [sp.dim]\\[y/n][/sp.dim] ") - return answer.strip().lower() in ("y", "yes") + "[sp.dim]\\[e]dit[/sp.dim] to steer, or press Enter to cancel: " + ).strip() + if answer.lower() == "run": + return ApprovalReply(approved=True) + if answer.lower() in ("e", "edit"): + return self._read_steer() + return ApprovalReply(approved=False) + answer = ( + self._console.input(" Approve? [sp.dim]\\[y]es / \\[e]dit / \\[n]o[/sp.dim] ") + .strip() + .lower() + ) + if answer in ("y", "yes"): + return ApprovalReply(approved=True) + if answer in ("e", "edit"): + return self._read_steer() + return ApprovalReply(approved=False) except (EOFError, KeyboardInterrupt): - return False + return ApprovalReply(approved=False) + + def _read_steer(self) -> ApprovalReply: + """Read one line of steering guidance; empty input = plain decline.""" + guidance = self._console.input(" Tell the model what to do instead:\n > ").strip() + return ApprovalReply(approved=False, steer_text=guidance or None) def ask_plan_approval(self, plan: TaskPlan, path: str) -> tuple[str, str]: self._spinner.stop() @@ -196,6 +363,38 @@ def config_files(workspace: Path, env: dict[str, str], paths: AppPaths) -> tuple return user_file, project_state_dir(workspace) / "config.toml" +def high_stakes_override_notice(config_dir: Path) -> str | None: + """One amber line naming any active high-stakes (egress/safety) override. + + A confirmed ``/config set`` for an egress/safety key persists in + ``overrides.json`` and silently outranks ``config.toml`` every future boot, + so surface it on each launch — egress must not quietly stay enabled. Returns + ``None`` when no such override is present. Pure (no I/O beyond the read) so + it is unit-testable; it gates nothing — the cloud-consent gate (§15.2) is + the egress boundary. + """ + overrides, _ = load_overrides(overrides_path(config_dir)) + active = {k: v for k, v in overrides.items() if k in HIGH_STAKES_KEYS} + if not active: + return None + pairs = ", ".join(f"{k}={active[k]!r}" for k in sorted(active)) + return ( + f"⚠ Active overrides: {pairs} — these override config.toml; /config unset to revert." + ) + + +def _relative_age(mtime: float, *, now: float | None = None) -> str: + """Compact relative age, e.g. "just now", "39m ago", "2h ago", "3d ago".""" + delta = max(0.0, (time.time() if now is None else now) - mtime) + if delta < 60: + return "just now" + if delta < 3600: + return f"{int(delta // 60)}m ago" + if delta < 86400: + return f"{int(delta // 3600)}h ago" + return f"{int(delta // 86400)}d ago" + + def run_interactive( workspace: Path, resume: str | None = None, model_override: str | None = None ) -> int: @@ -220,6 +419,9 @@ def load() -> LoadedConfig: return 2 for _warning in loaded.warnings: console.print(f"[dim]{escape(_warning)}[/dim]") + _override_notice = high_stakes_override_notice(user_file.parent) + if _override_notice is not None: + console.print(escape(_override_notice), style="sp.warn") settings = loaded.settings if settings.ui.no_color: console = build_console(settings) @@ -240,21 +442,48 @@ def load() -> LoadedConfig: installed = {m.name for m in installed_models} tty = console.is_terminal and sys.stdin.isatty() - if should_show_picker( + if not should_show_picker( tty=tty, model_override=model_override, installed_count=len(installed_models), ): - preselect = resolve_preselect(settings.model.default, load_last_model(workspace), installed) - chosen = choose_model(console, installed_models, preselect) - save_last_model(workspace, chosen) - else: chosen = settings.model.default + else: + last = load_last_model(workspace) + if last is not None and last in installed: + # Every boot after the first: Enter flies the last model, any other + # key opens the full menu (preselected on the last model). + if confirm_last_model(console, last): + chosen = last + else: + chosen = choose_model(console, installed_models, last) + else: + # First boot, or the last model is no longer installed. + chosen = choose_model( + console, + installed_models, + resolve_preselect(settings.model.default, last, installed), + ) + save_last_model(workspace, chosen) - if chosen not in installed: + # Cloud models are absent from the local /api/tags, so the availability gate + # is skipped for them (the typo-catch survives for local names). + if chosen not in installed and not is_cloud_model(chosen): console.print(f"[red]Model {chosen} is not installed.[/red] Try: ollama pull {chosen}") return 1 + # Cloud-egress consent boundary (design section 15.2): a cloud/remote model + # must clear allow_cloud + per-session consent BEFORE any prompt-bearing call + # touches it. Placed strictly before _preload — the first egress point — so a + # declined session performs no model load and no chat. (client.health/ + # list_models above hit /api/tags on base_url only: for the primary + # cloud-model case base_url is loopback → no egress; a non-loopback base_url + # is a metadata-only probe to the user's own configured endpoint, documented + # as an accepted residual in DESIGN §15.2.) + egressing_session = is_egressing(chosen, settings.model.base_url) + if not _resolve_cloud_consent(console, settings, chosen, tty=tty): + return 1 + # ------------------------------------------------------------------ # A9/A10: warm the chosen model into memory before the first turn. # Spinner shows "fueling "; errors are best-effort warnings. @@ -278,7 +507,10 @@ def _preload(model_name: str) -> None: ctx = detected or 8192 if settings.instructions.load_agents_md: cap = min(1500, ctx // 10) - behavior = load_behavior_instructions(paths.config_dir, workspace, max_tokens=cap) + project_trusted = _resolve_project_agents_trust(console, workspace, tty=tty) + behavior = load_behavior_instructions( + paths.config_dir, workspace, max_tokens=cap, project_trusted=project_trusted + ) else: behavior = BehaviorInstructions(global_text=None, project_text=None) @@ -296,8 +528,21 @@ def _preload(model_name: str) -> None: redact=settings.privacy.redact_secrets, ) audit.write("session_start", model=chosen) + if egressing_session: + # Record that the user granted cloud-egress consent for this session + # (consent already happened above; logged now that the logger exists). + audit.write( + "cloud_consent_granted", + model=chosen, + host=(urlsplit(settings.model.base_url).hostname or "").rstrip("."), + ) sessions_dir = SessionStore.sessions_dir(workspace) + # Capture prior sessions for the banner BEFORE this session's meta is + # written, so the current (empty) session never lists itself. + recent_sessions = [ + (label, _relative_age(mtime)) for label, mtime in SessionStore.recent(sessions_dir) + ] restored = None if resume is not None: session_path = ( @@ -339,7 +584,7 @@ def _preload(model_name: str) -> None: console.print("[sp.dim]Continuing without stored memory this session.[/sp.dim]") memory = None - ui = TerminalUI(console, glyphs=glyphs, spinner=settings.ui.spinner) + ui = TerminalUI(console, glyphs=glyphs, spinner=settings.ui.spinner, workspace=workspace) runtime = ConversationRuntime( llm=client, settings=settings, @@ -351,6 +596,7 @@ def _preload(model_name: str) -> None: session=session, memory=memory, skills=discovered_skills, + base_url=settings.model.base_url, ) if restored is not None: runtime.restore_history(restored.messages) @@ -371,9 +617,18 @@ def _preload(model_name: str) -> None: glyphs=glyphs, preload=_preload, attachments=attachments, + tty=tty, ) - console.print(banner(__version__, runtime.model, settings.runtime.security_profile)) + console.print( + render_banner( + runtime.model, + is_cloud=egressing_session, + profile=settings.runtime.security_profile, + skills=settings.skills.enabled, + recent_sessions=recent_sessions, + ) + ) if restored is not None: console.print( f"[sp.dim]Resumed session {escape(restored.session_id)} " @@ -392,8 +647,18 @@ def _preload(model_name: str) -> None: while True: status = runtime.status() + # Persistent, unspoofable active-cloud indicator (design section 15.2), + # now folded into the always-on status bar rather than a separate header. + # Derived from the harness egress signal on the LIVE model, so a + # mid-session /model use to a cloud model turns it on and switching back + # turns it off. ctx% is the same calculation the runtime reports. + egressing_now = is_egressing(runtime.model, settings.model.base_url) context = PromptContext( - workspace=status.workspace, model=status.model, profile=status.profile + workspace=status.workspace, + model=status.model, + profile=status.profile, + is_cloud=egressing_now, + ctx_pct=ctx_percent(status.estimated_prompt_tokens, status.budget.model_context_tokens), ) console.print() read_started = time.monotonic() @@ -412,6 +677,19 @@ def _preload(model_name: str) -> None: continue if not line: continue + if line.startswith("!"): + # `!` runs one command through the audited manual-shell path + # (raw shell=True, exactly like /shell); a bare `!` opens the shell + # loop. This is a human-only escape — model output never reaches this + # reader — so it carries the same trust as /shell. Live workspace + # honours a prior /cwd. + workspace = runtime.status().workspace + command = line[1:].strip() + if command: + run_manual_command(command, workspace, audit) + else: + manual_shell_loop(console, workspace, audit) + continue if line.startswith("/"): action = dispatcher.handle(line) if action is SlashAction.EXIT: diff --git a/shellpilot/config/loader.py b/shellpilot/config/loader.py index df07516..aa60d2e 100644 --- a/shellpilot/config/loader.py +++ b/shellpilot/config/loader.py @@ -49,13 +49,15 @@ class ConfigError(Exception): } ENV_MAP: dict[str, str] = { - "SHELLPILOT_OLLAMA_BASE_URL": "model.base_url", "SHELLPILOT_MODEL": "model.default", - "SHELLPILOT_PROFILE": "runtime.security_profile", "SHELLPILOT_NO_COLOR": "ui.no_color", "SHELLPILOT_UI_GLYPHS": "ui.glyphs", - # tools.web is deliberately absent: enabling network egress must be an - # explicit config-file act, not an ambient environment variable. + # Egress and security-posture keys (tools.web, model.base_url, + # model.allow_cloud, runtime.security_profile) are deliberately absent: + # enabling network egress or cloud models, redirecting the Ollama endpoint, + # or downgrading the security profile must be a DELIBERATE act (a config.toml + # edit or a confirm-gated /config set), never an ambient environment + # variable. This env-absence invariant is load-bearing. See HIGH_STAKES_KEYS. } ALLOWED_VALUES: dict[str, tuple[str, ...]] = { @@ -94,6 +96,34 @@ class ConfigError(Exception): "ui.glyphs", "ui.spinner", "tools.web", + "model.allow_cloud", + } +) + +# Structural tables/lists with no scalar CLI representation — settable ONLY by +# editing config.toml (the user or project layer), never via an env var, the +# program-managed overrides.json, or /config set. +CONFIG_FILE_ONLY_KEYS: frozenset[str] = frozenset( + { + "model.options", + "skills.enabled", + } +) + +# Egress / safety keys whose runtime change materially affects whether data +# leaves the device or the local approval posture. They are settable via the +# DELIBERATE /config set act — but only behind an amber warning + explicit +# confirmation (the HIGH_STAKES_KEYS gate in the slash handler) — and via a +# config.toml edit. They are deliberately ABSENT from ENV_MAP: an ambient env +# var is not a deliberate act, so it must never enable egress or downgrade the +# security posture. The per-session cloud-consent gate (§15.2) is the real +# egress boundary regardless of how allow_cloud was set. +HIGH_STAKES_KEYS: frozenset[str] = frozenset( + { + "tools.web", + "model.base_url", + "runtime.security_profile", + "model.allow_cloud", } ) @@ -299,16 +329,10 @@ def load_config( hint = f"; did you mean {close[0]!r}?" if close else "" warnings.append(f"overrides: unknown key {key!r}{hint} — entry ignored") continue - if key == "model.options": + if key in CONFIG_FILE_ONLY_KEYS: warnings.append( - "overrides: model.options is config-file only and cannot be set " - "via overrides — entry ignored" - ) - continue - if key == "skills.enabled": - warnings.append( - "overrides: skills.enabled is config-file only and cannot be set " - "via overrides — entry ignored" + f"overrides: {key} is config-file only and cannot be set " + f"via overrides — entry ignored" ) continue try: @@ -364,7 +388,7 @@ def validate_override(key: str, value: Any) -> Any: Raises :class:`ConfigError` for: - unknown keys (with a close-match hint when one exists) - - ``model.options`` (config-file only) + - config-file-only keys (see :data:`CONFIG_FILE_ONLY_KEYS`) - values that fail type/range/enum validation """ if key not in _SCHEMA: @@ -372,10 +396,8 @@ def validate_override(key: str, value: Any) -> Any: if close: raise ConfigError(f"unknown config key: {key!r}; did you mean {close[0]!r}?") raise ConfigError(f"unknown config key: {key!r}") - if key == "model.options": - raise ConfigError("model.options is config-file only and cannot be set via /config set") - if key == "skills.enabled": - raise ConfigError("skills.enabled is config-file only and cannot be set via /config set") + if key in CONFIG_FILE_ONLY_KEYS: + raise ConfigError(f"{key} is config-file only and cannot be set via /config set") # Coerce strings exactly as env-var parsing does. if isinstance(value, str): annotation = _SCHEMA[key] diff --git a/shellpilot/config/model.py b/shellpilot/config/model.py index 0a007d2..225cb09 100644 --- a/shellpilot/config/model.py +++ b/shellpilot/config/model.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import Any, Final +from shellpilot.llm.ollama import is_loopback_url + VALID_PROFILES = ("supervised", "balanced") # trusted-local arrives in v2 TESTED_FAMILIES: Final[tuple[str, ...]] = ("gemma4", "qwen3.5") @@ -15,6 +17,35 @@ def is_tested_model(name: str) -> bool: return any(name.startswith(family) for family in TESTED_FAMILIES) +def is_cloud_model(name: str) -> bool: + """True for an Ollama cloud model (the cloud tag, ``:cloud`` or ``-cloud``). + + Ollama proxies these through the local daemon, so the endpoint base_url + stays loopback while the prompt itself egresses to the provider. This is the + cloud-egress signal independent of base_url (design section 15.2). + + Ollama tags cloud models two ways: un-sized as ``model:cloud`` and sized + variants as ``model:-cloud`` (e.g. ``gemma4:31b-cloud``). Both are + matched on the tag (the segment after the final ``:``); missing either form + would class an egressing model as local, i.e. a silent egress, so detection + deliberately fails toward cloud. + """ + tag = name.rsplit(":", 1)[-1] + return tag == "cloud" or tag.endswith("-cloud") + + +def is_egressing(model: str, base_url: str) -> bool: + """True when a request for *model* on *base_url* leaves this device. + + A request egresses when the endpoint base_url is non-loopback OR the model + is a cloud model (an Ollama ``cloud``-tagged name egresses to the provider + even through a localhost Ollama proxy). The single source of truth for the cloud/remote + locality signal, shared by the boot consent gate, the runtime egress + chokepoint, and the active-cloud UI indicator (design section 15.2). + """ + return is_cloud_model(model) or not is_loopback_url(base_url) + + @dataclass(frozen=True) class ModelSettings: provider: str = "ollama" @@ -24,6 +55,13 @@ class ModelSettings: reasoning: bool = True base_url: str = "http://localhost:11434" keep_alive: str = "5m" + # Master cloud-egress switch (v0.10.0). Off by default keeps the local-first + # posture intact: a cloud/remote model boots only when the user has flipped + # this (in config.toml or a confirm-gated /config set) AND granted per-session + # consent. High-stakes and boot-only (see loader.HIGH_STAKES_KEYS / + # BOOT_ONLY_KEYS) — never enabled via an env var; the per-session consent gate + # is the real egress boundary regardless of how it was set. + allow_cloud: bool = False # Verbatim Ollama request `options`, passed through untouched (e.g. # repeat_penalty, repeat_last_n, temperature, seed). ShellPilot does NOT # validate individual keys — Ollama validates and errors at request time. diff --git a/shellpilot/llm/ollama.py b/shellpilot/llm/ollama.py index cbd38e6..d78f7ed 100644 --- a/shellpilot/llm/ollama.py +++ b/shellpilot/llm/ollama.py @@ -2,11 +2,12 @@ from __future__ import annotations +import ipaddress import json -import os from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import Any +from urllib.parse import urlsplit import httpx @@ -15,7 +16,6 @@ DEFAULT_BASE_URL = "http://localhost:11434" DEFAULT_TIMEOUT_SECONDS = 10.0 DEFAULT_GENERATE_TIMEOUT_SECONDS = 300.0 -BASE_URL_ENV_VAR = "SHELLPILOT_OLLAMA_BASE_URL" class OllamaError(Exception): @@ -39,7 +39,40 @@ class LocalModel: def resolve_base_url() -> str: - return os.environ.get(BASE_URL_ENV_VAR, DEFAULT_BASE_URL) + # The Ollama endpoint is a config-file-only setting (model.base_url); it is + # deliberately NOT read from an ambient env var, so a malicious environment + # cannot redirect where prompts are sent (audit F7). Clients constructed + # without an explicit base_url (e.g. doctor) fall back to the local default. + return DEFAULT_BASE_URL + + +def is_loopback_url(base_url: str) -> bool: + """True when *base_url* points at this box (loopback = local, no egress). + + The single source of truth for endpoint locality, shared by the runtime + egress chokepoint and the CLI boot consent gate so both agree on what + counts as off-box (design section 15.2). An empty/unset base_url falls back + to the local default and is local; ``localhost`` / ``*.localhost`` / + ``0.0.0.0`` and any loopback IP (127.0.0.0/8, ::1) or the unspecified + address are local. Anything else — a different literal host, OR a non-empty + but unparseable URL/host — is treated as remote (fail closed). + """ + if not base_url.strip(): + return True # unset → OllamaClient falls back to the local default + try: + host = (urlsplit(base_url).hostname or "").rstrip(".") + except ValueError: + return False # unparseable URL → remote (fail closed) + if not host: + return False # non-empty URL with no parseable host → remote (fail closed) + if host == "localhost" or host.endswith(".localhost") or host == "0.0.0.0": # noqa: S104 + return True + ip_str = host[1:-1] if host.startswith("[") and host.endswith("]") else host + try: + addr = ipaddress.ip_address(ip_str) + except ValueError: + return False + return addr.is_loopback or addr.is_unspecified class OllamaClient: diff --git a/shellpilot/memory/agents_md.py b/shellpilot/memory/agents_md.py index 9c83b71..dfc87cb 100644 --- a/shellpilot/memory/agents_md.py +++ b/shellpilot/memory/agents_md.py @@ -7,6 +7,7 @@ from __future__ import annotations +import hashlib from dataclasses import dataclass from pathlib import Path @@ -42,11 +43,36 @@ def _read_bounded(path: Path, max_tokens: int) -> str | None: return bounded +def project_agents_md_digest(workspace: Path) -> str | None: + """SHA-256 of the raw bytes of ``/AGENTS.md``, or None. + + Returns None when the file is absent, unreadable, or empty after stripping. + Hashing the raw content means any change at all flips the digest, so a + previously trusted project AGENTS.md must be re-accepted once it changes. + """ + try: + raw = (workspace / AGENTS_FILENAME).read_bytes() + except OSError: + return None + if not raw.strip(): + return None + return hashlib.sha256(raw).hexdigest() + + def load_behavior_instructions( - config_dir: Path, workspace: Path, max_tokens: int + config_dir: Path, + workspace: Path, + max_tokens: int, + *, + project_trusted: bool = True, ) -> BehaviorInstructions: - """Load global and project AGENTS.md, splitting the token budget between them.""" + """Load global and project AGENTS.md, splitting the token budget between them. + + The global ``/AGENTS.md`` is always trusted. The project + ``/AGENTS.md`` is loaded only when *project_trusted* is True + (trust-on-first-use, design section 16); when False it is skipped entirely. + """ per_file = max(1, max_tokens // 2) global_text = _read_bounded(config_dir / AGENTS_FILENAME, per_file) - project_text = _read_bounded(workspace / AGENTS_FILENAME, per_file) + project_text = _read_bounded(workspace / AGENTS_FILENAME, per_file) if project_trusted else None return BehaviorInstructions(global_text=global_text, project_text=project_text) diff --git a/shellpilot/memory/store.py b/shellpilot/memory/store.py index 5a1b265..f3dc7c8 100644 --- a/shellpilot/memory/store.py +++ b/shellpilot/memory/store.py @@ -212,7 +212,10 @@ class MemoryStores: global_store: MemoryStore project_store: MemoryStore - def render(self, max_tokens: int) -> str: + def render(self, max_tokens: int, *, meta: bool = False) -> str: + """Render the memory block. ``meta`` annotates each preference with its + (scope, source) for the ``/memory show`` view; the default is the + injected prompt format and must stay byte-identical.""" preferences = list(self.global_store.preferences) + list(self.project_store.preferences) facts = list(self.global_store.facts) + list(self.project_store.facts) if not preferences and not facts: @@ -220,7 +223,10 @@ def render(self, max_tokens: int) -> str: lines = ["## Memory"] if preferences: lines.append("Preferences:") - lines.extend(f"- [{p.id}] {p.text}" for p in preferences) + if meta: + lines.extend(f"- [{p.id}] ({p.scope}, {p.source}) {p.text}" for p in preferences) + else: + lines.extend(f"- [{p.id}] {p.text}" for p in preferences) if facts: lines.append("Project facts:") lines.extend(f"- [{f.id}] ({f.kind}) {f.label}: {f.value}" for f in facts) diff --git a/shellpilot/persistence/audit_store.py b/shellpilot/persistence/audit_store.py index 8bcb31b..2a89f08 100644 --- a/shellpilot/persistence/audit_store.py +++ b/shellpilot/persistence/audit_store.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -39,8 +40,9 @@ def write(self, event: str, **fields: Any) -> None: "event": event, } record.update({key: _redact_value(value, self.redact) for key, value in fields.items()}) - self.path.parent.mkdir(parents=True, exist_ok=True) - with self.path.open("a", encoding="utf-8") as handle: + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + with os.fdopen(fd, "a", encoding="utf-8") as handle: handle.write(json.dumps(record, ensure_ascii=False) + "\n") def tail(self, count: int = 20, *, session_id: str | None = None) -> list[dict[str, Any]]: diff --git a/shellpilot/persistence/sessions.py b/shellpilot/persistence/sessions.py index e2ab9c3..ae3ca98 100644 --- a/shellpilot/persistence/sessions.py +++ b/shellpilot/persistence/sessions.py @@ -10,6 +10,7 @@ from __future__ import annotations import json +import os import time import uuid from dataclasses import dataclass @@ -62,6 +63,50 @@ def find(directory: Path, session_id: str) -> Path | None: candidate = directory / f"{session_id}.jsonl" return candidate if candidate.is_file() else None + @staticmethod + def recent(directory: Path, limit: int = 3) -> list[tuple[str, float]]: + """Newest `limit` sessions as ``(label, mtime)`` pairs, newest first. + + The label is a short snippet of the session's first user message — + the most recognizable real field — falling back to the session's model + name when the transcript has no user message yet. + """ + if not directory.is_dir(): + return [] + files = sorted(directory.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True) + out: list[tuple[str, float]] = [] + for path in files[:limit]: + out.append((SessionStore._session_label(path), path.stat().st_mtime)) + return out + + @staticmethod + def _session_label(path: Path) -> str: + """First user message (truncated) or, failing that, the model name.""" + model = "" + try: + with path.open(encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(record, dict): + continue + if record.get("type") == "meta": + model = record.get("model", model) + elif record.get("type") == "message" and record.get("role") == "user": + snippet = " ".join(str(record.get("content", "")).split()) + if snippet: + return snippet[:32] + "…" if len(snippet) > 32 else snippet + except OSError: + pass + # NOTE: no user message recorded yet — fall back to the model name + # (the only other recognizable real field) rather than the session id. + return model or path.stem + def write_meta(self, *, model: str, profile: str, workspace: Path) -> None: self._append( { @@ -100,8 +145,9 @@ def record_active_plan(self, task_id: str | None) -> None: self._append({"type": "active_plan", "task_id": task_id}) def _append(self, record: dict[str, Any]) -> None: - self.path.parent.mkdir(parents=True, exist_ok=True) - with self.path.open("a", encoding="utf-8") as handle: + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + with os.fdopen(fd, "a", encoding="utf-8") as handle: handle.write(json.dumps(record, ensure_ascii=False) + "\n") @staticmethod diff --git a/shellpilot/persistence/workspace_state.py b/shellpilot/persistence/workspace_state.py index b2dfeb4..f4537b9 100644 --- a/shellpilot/persistence/workspace_state.py +++ b/shellpilot/persistence/workspace_state.py @@ -1,14 +1,17 @@ """Workspace harness state persisted in .shellpilot/state.json (design section 17). This file stores harness-internal state for a workspace — not user config. -Currently it records the last model selected by the user so that the boot -picker (Task A8) can pre-select it on the next launch. +It records the last model selected by the user (so the boot picker can +pre-select it on the next launch) and the digest of the project AGENTS.md the +user has accepted (trust-on-first-use, design section 16). Saving one key +must never clobber the other, so all writers read-merge-write the whole state. """ from __future__ import annotations import json from pathlib import Path +from typing import Any from shellpilot.persistence.json_store import atomic_write_json from shellpilot.persistence.paths import project_state_dir @@ -21,35 +24,72 @@ def state_path(workspace: Path) -> Path: return project_state_dir(workspace) / "state.json" -def load_last_model(workspace: Path) -> str | None: - """Return the last model name stored for *workspace*, or None on any problem. +def _load_state(workspace: Path) -> dict[str, Any]: + """Return the parsed state dict, or ``{}`` on any problem. - Returns None (never raises) when the file is absent, unreadable, - contains invalid JSON, carries an unexpected version, or is missing - a string *last_model* key. + Returns ``{}`` (never raises) when the file is absent, unreadable, + contains invalid JSON, or carries an unexpected version. """ path = state_path(workspace) try: raw = path.read_text(encoding="utf-8") data = json.loads(raw) except (OSError, json.JSONDecodeError, UnicodeDecodeError): - return None + return {} if not isinstance(data, dict): - return None + return {} if data.get("version") != STATE_VERSION: - return None - model = data.get("last_model") - if not isinstance(model, str): - return None - return model + return {} + return data -def save_last_model(workspace: Path, model: str) -> None: - """Persist *model* as the last-selected model for *workspace*. +def _save_state(workspace: Path, data: dict[str, Any]) -> None: + """Atomically persist *data* (stamping the current version). - Creates ``.shellpilot/`` if it does not exist yet. - Writes atomically so a crash mid-write leaves the previous state intact. + Creates ``.shellpilot/`` if it does not exist yet. Writes atomically so a + crash mid-write leaves the previous state intact. """ path = state_path(workspace) path.parent.mkdir(parents=True, exist_ok=True) - atomic_write_json(path, {"version": STATE_VERSION, "last_model": model}) + data = {**data, "version": STATE_VERSION} + atomic_write_json(path, data) + + +def load_last_model(workspace: Path) -> str | None: + """Return the last model name stored for *workspace*, or None on any problem. + + Returns None (never raises) when the state is absent/invalid or is missing + a string *last_model* key. + """ + model = _load_state(workspace).get("last_model") + return model if isinstance(model, str) else None + + +def save_last_model(workspace: Path, model: str) -> None: + """Persist *model* as the last-selected model for *workspace*. + + Merges into existing state so the trusted-AGENTS.md digest is preserved. + """ + state = _load_state(workspace) + state["last_model"] = model + _save_state(workspace, state) + + +def load_trusted_agents_digest(workspace: Path) -> str | None: + """Return the accepted project-AGENTS.md digest for *workspace*, or None. + + Returns None when the state is absent/invalid or the key is missing or + not a string. + """ + digest = _load_state(workspace).get("trusted_agents_md") + return digest if isinstance(digest, str) else None + + +def save_trusted_agents_digest(workspace: Path, digest: str) -> None: + """Persist *digest* as the accepted project-AGENTS.md fingerprint. + + Merges into existing state so the last-selected model is preserved. + """ + state = _load_state(workspace) + state["trusted_agents_md"] = digest + _save_state(workspace, state) diff --git a/shellpilot/policy/approvals.py b/shellpilot/policy/approvals.py index 211f580..d5ba565 100644 --- a/shellpilot/policy/approvals.py +++ b/shellpilot/policy/approvals.py @@ -28,6 +28,27 @@ class ApprovalRequest: diff: str = "" +@dataclass(frozen=True) +class ApprovalReply: + """The three-way outcome of an approval prompt (design section 14.6). + + approved=True -> run the action. + approved=False, steer=None -> plain decline; the action does not run. + approved=False, steer=text -> reject-and-steer: the action does NOT run and + the user's guidance is fed back to the model, which re-proposes a + corrected action through the normal classify->decide->gate flow. Steering + never executes the un-approved action; safety is automatic because the + re-proposal re-enters the gate like any tool call. + """ + + approved: bool + steer_text: str | None = None + + +APPROVE = ApprovalReply(approved=True) +DECLINE = ApprovalReply(approved=False) + + def decide( profile: str, side_effect: SideEffect, diff --git a/shellpilot/policy/command_policy.py b/shellpilot/policy/command_policy.py index bec7b71..bfc954c 100644 --- a/shellpilot/policy/command_policy.py +++ b/shellpilot/policy/command_policy.py @@ -34,12 +34,14 @@ "rg", "fgrep", "egrep", - "pytest", "true", "false", "ps", } ) +READER_EXECUTABLES: Final = frozenset( + {"cat", "head", "tail", "grep", "egrep", "fgrep", "rg", "wc", "file", "stat", "du"} +) GIT_READONLY_VERBS: Final = frozenset( { "status", @@ -57,6 +59,19 @@ } ) GIT_HIGH: Final = frozenset({"reset", "clean"}) +GIT_BENIGN_GLOBALS: Final = frozenset({"--no-pager", "--literal-pathspecs"}) +GIT_TERMINAL_GLOBALS: Final = frozenset({"--exec-path"}) +GIT_GLOBALS_WITH_SPLIT_VALUES: Final = frozenset( + { + "-C", + "-c", + "--config-env", + "--git-dir", + "--namespace", + "--super-prefix", + "--work-tree", + } +) SHELLS: Final = frozenset({"sh", "bash", "zsh", "fish", "dash", "ksh"}) PACKAGE_MANAGERS: Final = frozenset( { @@ -142,8 +157,8 @@ def sensitive_path_reason(path: Path) -> str | None: return None -def _writes_outside_workspace(argv: list[str], workspace: Path) -> str | None: - """For write-ish commands, flag path arguments that resolve outside the workspace. +def _path_arg_outside_workspace(argv: list[str], workspace: Path) -> str | None: + """Flag path arguments that resolve outside the workspace (reads or writes). Both absolute and relative tokens are checked. Bare non-path tokens (e.g. "git", "status", a commit message) resolve to workspace/, which is @@ -167,20 +182,68 @@ def _writes_outside_workspace(argv: list[str], workspace: Path) -> str | None: return None +def _scan_git_verb(argv: list[str]) -> tuple[str, list[str], bool]: + conservative_global = False + index = 1 + while index < len(argv): + token = argv[index] + if not token.startswith("-"): + return token, argv[index + 1 :], conservative_global + if token not in GIT_BENIGN_GLOBALS: + conservative_global = True + if token in GIT_TERMINAL_GLOBALS: + return "", [], conservative_global + if token in GIT_GLOBALS_WITH_SPLIT_VALUES: + index += 2 + else: + index += 1 + return "", [], conservative_global + + +def _is_git_branch_delete_flag(flag: str) -> bool: + if flag.startswith("--"): + option = flag.partition("=")[0] + return len(option) >= len("--dele") and "--delete".startswith(option) + return flag.startswith("-") and any(option in "dD" for option in flag[1:]) + + +def _is_git_force_with_lease_flag(flag: str) -> bool: + option = flag.partition("=")[0] + return len(option) >= len("--force-w") and "--force-with-lease".startswith(option) + + +def _is_git_push_short_force_flag(flag: str) -> bool: + if flag.startswith("--"): + return False + for option in flag[1:]: + if option == "f": + return True + if option == "o": + return False + return False + + def _classify_git(argv: list[str]) -> CommandRisk: - verb = next((token for token in argv[1:] if not token.startswith("-")), "") + verb, verb_args, conservative_global = _scan_git_verb(argv) flags = [token for token in argv[1:] if token.startswith("-")] if verb in GIT_HIGH: return CommandRisk(RiskLevel.HIGH, (f"git {verb} can destroy local changes",)) - if verb == "branch" and ("-D" in flags or "--delete" in flags): + if verb == "branch" and any(_is_git_branch_delete_flag(flag) for flag in flags): return CommandRisk(RiskLevel.HIGH, ("git branch deletion",)) if verb == "push": - if "--force" in flags or "-f" in flags or "--force-with-lease" in flags: + if ( + "--force" in flags + or any(_is_git_push_short_force_flag(flag) for flag in flags) + or any(_is_git_force_with_lease_flag(flag) for flag in flags) + or any(arg.startswith("+") for arg in verb_args) + ): return CommandRisk(RiskLevel.HIGH, ("force push rewrites remote history",)) return CommandRisk(RiskLevel.MEDIUM, ("git push publishes commits",)) + if conservative_global: + return CommandRisk(RiskLevel.MEDIUM, ("git uses a non-benign global option",)) if verb in GIT_READONLY_VERBS and verb != "stash": return CommandRisk(RiskLevel.LOW, ()) - if verb == "stash" and len(argv) > 2 and argv[2] in ("list", "show"): + if verb == "stash" and verb_args and verb_args[0] in ("list", "show"): return CommandRisk(RiskLevel.LOW, ()) return CommandRisk(RiskLevel.MEDIUM, (f"git {verb or '?'} changes repository state",)) @@ -191,7 +254,7 @@ def _classify_rm(argv: list[str], workspace: Path) -> CommandRisk: return CommandRisk(RiskLevel.HIGH, ("recursive delete",)) if any("*" in token for token in argv[1:]): return CommandRisk(RiskLevel.HIGH, ("glob delete",)) - outside = _writes_outside_workspace(argv, workspace) + outside = _path_arg_outside_workspace(argv, workspace) if outside: return CommandRisk(RiskLevel.HIGH, ("deletes outside the workspace",)) return CommandRisk(RiskLevel.MEDIUM, ("deletes a file",)) @@ -203,6 +266,12 @@ def classify_command(argv: list[str], *, workspace: Path) -> CommandRisk: return CommandRisk(RiskLevel.BLOCKED, ("empty command",)) executable = Path(argv[0]).name + if argv[0] != executable: + risk = classify_command([executable, *argv[1:]], workspace=workspace) + if risk.risk == RiskLevel.LOW: + return CommandRisk(RiskLevel.MEDIUM, ("path-qualified executable",)) + return risk + secret = _touches_secret_path(argv) if executable in HIGH_COMMANDS: @@ -234,16 +303,29 @@ def classify_command(argv: list[str], *, workspace: Path) -> CommandRisk: if executable in NETWORK_COMMANDS: return CommandRisk(RiskLevel.MEDIUM, (f"{executable} performs network activity",)) if executable in WRITE_COMMANDS: - outside = _writes_outside_workspace(argv, workspace) + outside = _path_arg_outside_workspace(argv, workspace) if outside: return CommandRisk(RiskLevel.HIGH, (outside,)) return CommandRisk(RiskLevel.MEDIUM, (f"{executable} writes to the workspace",)) if executable == "kill": return CommandRisk(RiskLevel.MEDIUM, ("signals a process",)) if executable in ("python", "python3"): - if argv[1:3] == ["-m", "pytest"] or "--version" in argv: + if argv[1:] == ["--version"]: return CommandRisk(RiskLevel.LOW, ()) return CommandRisk(RiskLevel.MEDIUM, ("runs arbitrary python code",)) + if executable in READER_EXECUTABLES: + # Unlike read_file (which honors allow_sensitive_reads via decide()), + # classify_command sees only a RiskLevel and run_command is + # SideEffect.VARIABLE, so out-of-workspace command reads escalate to + # HIGH unconditionally and never AUTO-run. A regex/pattern arg that + # looks path-like (e.g. grep "../x") can over-flag to HIGH; that is a + # safe over-ask (HIGH -> ASK), shared with the rm/WRITE branches. + outside = _path_arg_outside_workspace(argv, workspace) + if outside: + return CommandRisk( + RiskLevel.HIGH, (f"reads outside the workspace boundary: {outside}",) + ) + # in-workspace readers fall through to the LOW return below if executable in LOW_EXECUTABLES: return CommandRisk(RiskLevel.LOW, ()) diff --git a/shellpilot/prompts/system.py b/shellpilot/prompts/system.py index 9c63c00..de61d2c 100644 --- a/shellpilot/prompts/system.py +++ b/shellpilot/prompts/system.py @@ -5,13 +5,31 @@ from pathlib import Path # Tracks behavioral prompt revisions: v1 = initial, v2 = plan-discipline hardening, -# v3 = proposal/execution split, v4 = Skills v2 builtin resources/triggers. -PROMPT_VERSION = 4 +# v3 = proposal/execution split, v4 = Skills v2 builtin resources/triggers, +# v5 = conditional locality line (honest under opt-in cloud egress). +PROMPT_VERSION = 5 + +# Local (non-egressing) opening: byte-identical to the pre-v0.10.0 prompt so the +# gemma4 baseline session is unchanged. +_LOCAL_OPENING = ( + "You are ShellPilot, a local AI shell harness running entirely on this machine " + "through Ollama. You have no independent network access — any internet contact " + "happens only through explicitly registered tools, and every such call requires " + "the user's approval." +) + +# Egressing opening: the honest line when the session runs on a cloud/remote model. +# The "entirely on this machine / no independent network access" claim is FALSE +# when the prompt leaves the device, so it must not be asserted. +_EGRESS_OPENING = ( + "You are ShellPilot, an AI shell harness driven through Ollama. You may be running " + "on a remote model: this session's content (system prompt, files, command output, " + "memory) leaves this device. Internet contact still happens only through explicitly " + "registered tools, and every such call requires the user's approval." +) _BASE = """\ -You are ShellPilot, a local AI shell harness running entirely on this machine through Ollama. \ -You have no independent network access — any internet contact happens only through \ -explicitly registered tools, and every such call requires the user's approval. +{opening} Workspace: {workspace} Security profile: {profile} @@ -45,8 +63,13 @@ def build_system_prompt( workspace: Path, profile: str, behavior_block: str = "", + is_egressing: bool = False, ) -> str: - prompt = _BASE.format(workspace=workspace, profile=profile) + # Default is_egressing=False keeps every existing caller — and the gemma4 + # local baseline — byte-identical. Only an egressing (cloud/remote) session + # swaps in the honest locality line (design section 15.2). + opening = _EGRESS_OPENING if is_egressing else _LOCAL_OPENING + prompt = _BASE.format(opening=opening, workspace=workspace, profile=profile) if behavior_block: prompt = f"{prompt}\n\n{behavior_block}" return prompt diff --git a/shellpilot/runtime/context.py b/shellpilot/runtime/context.py index 2f3300f..32a456a 100644 --- a/shellpilot/runtime/context.py +++ b/shellpilot/runtime/context.py @@ -283,7 +283,7 @@ def assemble( # Build the readable menu: one line advertising on-demand docs for injected skills. # Gate mirrors skill_read registration: present only when enabled is non-empty. - # ponytail: not budget-counted — it's a bounded single line, not skill content. + # NOTE: not budget-counted — it's a bounded single line, not skill content. readable_block: ContextBlock | None = None if trigger_ctx.enabled and readable: parts = "; ".join(f"{name}: {', '.join(names)}" for name, names in readable) diff --git a/shellpilot/runtime/conversation.py b/shellpilot/runtime/conversation.py index 32b65c2..e721cb3 100644 --- a/shellpilot/runtime/conversation.py +++ b/shellpilot/runtime/conversation.py @@ -2,17 +2,20 @@ from __future__ import annotations +import dataclasses import json import time from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path +from urllib.parse import urlsplit -from shellpilot.config.model import Settings +from shellpilot.config.model import Settings, is_egressing from shellpilot.llm.client import LLMClient from shellpilot.llm.messages import ImageRef, Message, tool_result, user -from shellpilot.llm.ollama import encode_tool +from shellpilot.llm.ollama import encode_tool, is_loopback_url from shellpilot.memory.agents_md import BehaviorInstructions +from shellpilot.memory.redaction import redact_secrets, redact_structure from shellpilot.memory.store import MemoryStores from shellpilot.persistence.audit_store import AuditLogger from shellpilot.persistence.sessions import SessionStore @@ -39,7 +42,7 @@ TOOL_DIGEST_TAIL = 200 MAX_PLAN_NUDGES = 2 MAX_EMPTY_NUDGES = 2 -# ponytail: tunable ceiling separating a real end-of-plan summary from a terse +# NOTE: tunable ceiling separating a real end-of-plan summary from a terse # "done." When a completing reply already carries content this long, the streamed # prose IS the single summary and the redundant re-summary round is skipped. MIN_SUMMARY_CHARS = 80 @@ -124,12 +127,17 @@ def __init__( session: SessionStore | None = None, memory: MemoryStores | None = None, skills: Sequence[Skill] | None = None, + base_url: str = "http://localhost:11434", ) -> None: self._audit = audit self._session = session self._memory = memory self._llm = llm self._settings = settings + # The model endpoint URL — the egress-locality signal. The default is + # loopback, so every existing caller and test (FakeLLM) is non-egressing + # and behaviour is byte-identical. + self._base_url = base_url self._workspace = workspace self._behavior = behavior self._ui = ui @@ -267,6 +275,54 @@ def update_settings(self, settings: Settings) -> None: self._settings = settings self.budget = self._resolve_budget() + def _endpoint_host(self) -> str: + """Host of the model endpoint (for audit); empty when unparseable.""" + return (urlsplit(self._base_url).hostname or "").rstrip(".") + + def _endpoint_is_loopback(self) -> bool: + """True when the model endpoint is on this box (loopback = local). + + Delegates to the shared ``is_loopback_url`` helper so the runtime egress + chokepoint and the CLI boot consent gate use one definition of off-box. + """ + return is_loopback_url(self._base_url) + + def _is_egressing(self) -> bool: + """True when a model request leaves this device. + + Delegates to the shared ``is_egressing`` predicate so the runtime egress + chokepoint, the boot consent gate, and the active-cloud UI indicator all + agree on what counts as off-box (design section 15.2). + """ + return is_egressing(self._model, self._base_url) + + def _redacted_for_egress(self, messages: list[Message]) -> list[Message]: + """Best-effort redacted COPY of *messages* for a remote send. + + Defence-in-depth, NOT a guarantee: regex redaction misses novel secret + formats, and image/base64 data is left as-is (not redactable here — this + is disclosed, not protected). Never mutates ``self._history``: each + Message is rebuilt via dataclasses.replace with its content run through + redact_secrets and any tool-call arguments through redact_structure. + """ + out: list[Message] = [] + for message in messages: + redacted_calls = tuple( + dataclasses.replace( + call, + arguments=redact_structure(call.arguments), # type: ignore[arg-type] + ) + for call in message.tool_calls + ) + out.append( + dataclasses.replace( + message, + content=redact_secrets(message.content), + tool_calls=redacted_calls, + ) + ) + return out + def clear_history(self) -> None: self._history.clear() self.plan_manager.cancel() @@ -286,6 +342,7 @@ def _context_snapshot(self) -> ContextSnapshot: base_prompt = build_system_prompt( workspace=self._workspace, profile=self._settings.runtime.security_profile, + is_egressing=self._is_egressing(), ) memory_block = "" if self._memory is not None: @@ -507,11 +564,31 @@ def _tool_loop(self) -> Message: Message(role="system", content=self._system_message_text()), *self._history, ] + egressing = self._is_egressing() + if egressing and self._audit is not None: + # Egress visibility (F10/F12): record THAT a request left the + # device, to where, and how much — counts and host/model only, + # never message bodies. AuditLogger stamps workspace/session/ts. + self._audit.write( + "model_request", + host=self._endpoint_host(), + model=self._model, + locality="remote", + message_count=len(messages), + approx_bytes=sum(len(m.content or "") for m in messages), + image_count=sum(len(m.images) for m in messages if m.images), + ) + # Outbound redaction (F3) — best-effort DiD applied ONLY to remote + # turns: a loopback send is passed byte-identical (no copy). Images + # and novel-format secrets are NOT redactable here and still egress. + send_messages = messages + if egressing and self._settings.privacy.redact_secrets: + send_messages = self._redacted_for_egress(messages) self._ui.begin_response() try: reply = self._llm.chat( self._model, - messages, + send_messages, tools=tools, num_ctx=self.budget.model_context_tokens, options=self._settings.model.options, diff --git a/shellpilot/runtime/events.py b/shellpilot/runtime/events.py index a1f8c43..c7d2c5f 100644 --- a/shellpilot/runtime/events.py +++ b/shellpilot/runtime/events.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Protocol if TYPE_CHECKING: - from shellpilot.policy.approvals import ApprovalRequest + from shellpilot.policy.approvals import ApprovalReply, ApprovalRequest from shellpilot.runtime.planner import TaskPlan @@ -45,7 +45,7 @@ def show_tool_result(self, name: str, success: bool, summary: str) -> None: ... def show_command_output(self, line: str) -> None: ... - def ask_approval(self, request: ApprovalRequest) -> bool: ... + def ask_approval(self, request: ApprovalRequest) -> ApprovalReply: ... def ask_plan_approval(self, plan: TaskPlan, path: str) -> tuple[str, str]: """Returns (choice, revision_text); choice is 'y', 'e', or 'n'.""" diff --git a/shellpilot/runtime/executor.py b/shellpilot/runtime/executor.py index bc9e660..43d2c9c 100644 --- a/shellpilot/runtime/executor.py +++ b/shellpilot/runtime/executor.py @@ -15,7 +15,13 @@ from shellpilot.llm.messages import ToolCall, ToolDefinition from shellpilot.persistence.audit_store import AuditLogger from shellpilot.persistence.snapshots import SnapshotStore -from shellpilot.policy.approvals import ApprovalRequest, Decision, decide +from shellpilot.policy.approvals import ( + DECLINE, + ApprovalReply, + ApprovalRequest, + Decision, + decide, +) from shellpilot.policy.explanations import explain_risk from shellpilot.policy.risk import RiskLevel, SideEffect from shellpilot.runtime.budget import estimate_tokens, truncate_to_tokens @@ -25,10 +31,11 @@ ToolResult, schema_reminder, validate_args, + workspace_display, ) from shellpilot.tools.registry import ToolRegistry -ApprovalAsker = Callable[[ApprovalRequest], bool] +ApprovalAsker = Callable[[ApprovalRequest], ApprovalReply] @dataclass(frozen=True) @@ -160,25 +167,49 @@ def execute(self, call: ToolCall) -> ExecutionOutcome: purpose=purpose, diff=diff, ) - approved = self._ask_approval(request) if self._ask_approval else False + reply = self._ask_approval(request) if self._ask_approval else DECLINE + steer = reply.steer_text if not reply.approved else None self._log( "approval", call, display, classification.risk, explanation=purpose, - decision="approved" if approved else "rejected", + decision="approved" if reply.approved else ("steered" if steer else "rejected"), ) - if not approved: - return ExecutionOutcome( - model_text=( + if not reply.approved: + # Reject-and-steer: the un-approved action NEVER runs (design + # section 14.6). On a plain decline we tell the model not to + # retry; on a steer we feed the user's guidance back so the + # model re-proposes a corrected action, which re-enters this + # same classify->decide->gate flow as any tool call. + if steer: + model_text = ( + f"tool: {call.name}\nstatus: declined\nsummary: the user declined " + f"this action and asks you to do this instead: {steer}. " + "Propose a corrected action." + ) + summary = "steered by user" + else: + model_text = ( f"tool: {call.name}\nstatus: declined\nsummary: the user declined " "this action. Do not retry it; ask the user how to proceed if needed." - ), + ) + summary = "declined by user" + return ExecutionOutcome( + model_text=model_text, malformed=False, - result=ToolResult(success=False, summary="declined by user", content=""), + result=ToolResult(success=False, summary=summary, content=""), ) + # Egress visibility (F12): a NETWORK-side-effect tool sends a query/url + # off-box regardless of model locality. Record it here — after every + # gate has passed, immediately before the call actually leaves the box — + # so a blocked/declined call (which never ran) produces no egress + # record. The AuditLogger redacts the args (e.g. a secret in a URL). + if spec.side_effect is SideEffect.NETWORK and self._audit is not None: + self._audit.write("web_egress", tool=call.name, args=dict(call.arguments)) + try: result = spec.handler(context, call.arguments) except ToolError as exc: @@ -219,15 +250,25 @@ def _log( if self._audit is not None: self._audit.write(event, tool=call.name, command=display, risk=risk.value, **fields) - @staticmethod - def _display_for(call: ToolCall) -> str: + def _display_for(self, call: ToolCall) -> str: if call.name == "run_command": argv = call.arguments.get("argv") if isinstance(argv, list): return " ".join(str(token) for token in argv) - rendered = ", ".join(f"{key}={value!r}" for key, value in call.arguments.items()) + rendered = ", ".join( + f"{key}={self._display_value(key, value)}" for key, value in call.arguments.items() + ) return f"{call.name}({rendered})" + def _display_value(self, key: str, value: Any) -> str: + # Display-integrity (design section 14.5): a `path` argument is shown as + # the resolved, workspace-relative target — the SAME resolution the + # handler acts on — so the approval display can never diverge from the + # file actually touched. All other args render verbatim. + if key == "path" and isinstance(value, str): + return repr(workspace_display(self._workspace, value)) + return repr(value) + def _render(self, name: str, result: ToolResult) -> str: status = "ok" if result.success else "failed" header = f"tool: {name}\nstatus: {status}\nsummary: {result.summary}" diff --git a/shellpilot/runtime/planner.py b/shellpilot/runtime/planner.py index c80f3e0..0c0e291 100644 --- a/shellpilot/runtime/planner.py +++ b/shellpilot/runtime/planner.py @@ -452,7 +452,7 @@ def _propose(context: ToolContext, arguments: dict[str, Any]) -> ToolResult: # (manager.create stores goal as-is and each step verbatim as # PlanStep.title), so a byte-identical re-emit matches while any real # goal/step change falls through to the recreate branch below. - # ponytail: a deliberate identical "restart" re-propose is swallowed — + # NOTE: a deliberate identical "restart" re-propose is swallowed — # far rarer than the duplicate-emit this prevents. return ToolResult( success=True, diff --git a/shellpilot/skills/builtin/code-review/SKILL.md b/shellpilot/skills/builtin/code-review/SKILL.md new file mode 100644 index 0000000..a988ade --- /dev/null +++ b/shellpilot/skills/builtin/code-review/SKILL.md @@ -0,0 +1,9 @@ +--- +name: code-review +description: Review a change across correctness, security, clarity, tests, and scope. +--- +Review a change against the work it was meant to do, then across fixed dimensions so nothing is skipped: correctness (does it do what's asked, including edge cases), security (untrusted input, secrets, unsafe calls), clarity (will the next reader follow it), tests (is the new behavior actually covered), and scope (does the diff do only what was asked, no unrelated changes). Read the diff with `run_command` (`git diff`), the surrounding code with `read_file`, and find related call sites with `search_text` — a change is only correct in context. + +Be specific: name the file, the line, and the concrete risk, not a vague concern. + +One on-demand reference gives concrete prompts per dimension — open it with `skill_read(skill="code-review", resource="dimensions")`. diff --git a/shellpilot/skills/builtin/code-review/references/dimensions.md b/shellpilot/skills/builtin/code-review/references/dimensions.md new file mode 100644 index 0000000..1dd4138 --- /dev/null +++ b/shellpilot/skills/builtin/code-review/references/dimensions.md @@ -0,0 +1,40 @@ +# Review Dimensions + +Concrete questions to ask per dimension. Read the diff in the context of the surrounding code — a line that is correct in isolation can be wrong in its caller. + +## Correctness + +- Does it actually do what the task asked, or only the easy part of it? +- Edge cases: empty input, zero, negative, very large, null/None, the first and last element, concurrent access. +- Off-by-one in loops and slices; boundary conditions on comparisons (`<` vs `<=`). +- Error paths: are failures handled, or swallowed silently? Are exceptions caught too broadly? +- Does it match the existing behavior it's supposed to extend, or quietly change it? + +## Security + +- Untrusted input reaching a shell, query, file path, or eval — is it validated and escaped? +- Secrets, keys, or tokens hardcoded, logged, or committed. +- Path traversal (`..`), symlink following, writing outside the intended directory. +- Permission and authorization checks present where state changes. +- New dependencies — are they needed, and from a trustworthy source? + +## Clarity + +- Will the next reader understand this without the author present? Names that say what, not how. +- Is complexity justified, or is there a simpler shape? Flag speculative abstraction. +- Comments explain *why*, not restate *what*. Dead code and leftover debugging removed. + +## Tests + +- Is the new behavior covered, including the edge cases above — not just the happy path? +- Would the tests actually fail if the change were wrong? A test that passes against a broken implementation tests nothing. +- For a bug fix: is there a regression test that fails before the fix? + +## Scope + +- Does the diff do only what was asked? Flag unrelated refactors, formatting churn, and "while I was here" changes — they hide the real change and complicate review and rollback. +- Are there now-dead branches or unused parameters left behind? + +## Delivering the review + +Lead with anything blocking (correctness, security). For each finding give file, line, and the specific risk or fix — "`parse.py:42` — `index` can be -1 when the list is empty, raising IndexError" beats "possible bug in parsing". Separate must-fix from nice-to-have. diff --git a/shellpilot/skills/builtin/debugging/SKILL.md b/shellpilot/skills/builtin/debugging/SKILL.md new file mode 100644 index 0000000..fcbfb76 --- /dev/null +++ b/shellpilot/skills/builtin/debugging/SKILL.md @@ -0,0 +1,12 @@ +--- +name: debugging +description: Systematic debugging — reproduce, hypothesize, isolate, fix the cause, verify. +--- +Debug by method, not by guessing. Reproduce the failure first — you cannot fix what you cannot trigger. Form one hypothesis about the cause, then isolate it (narrow the input, bisect the history, or add a probe) before editing. Fix the root cause, not the symptom, one change at a time. Then verify by re-running the exact reproduction. + +Inspect with `read_file` and `search_text`; reproduce and re-check with `run_command`. + +Two on-demand references — open with `skill_read(skill="debugging", resource="")`: + +- `method` — the full loop, with how to bisect and write a minimal reproduction. +- `common-traps` — patterns that waste time (symptom-fixing, changing several things at once, trusting an unconfirmed cause). diff --git a/shellpilot/skills/builtin/debugging/references/common-traps.md b/shellpilot/skills/builtin/debugging/references/common-traps.md new file mode 100644 index 0000000..b637cb1 --- /dev/null +++ b/shellpilot/skills/builtin/debugging/references/common-traps.md @@ -0,0 +1,31 @@ +# Common Debugging Traps + +The patterns that turn a ten-minute fix into an hour. Watch for these in your own process. + +## Fixing the symptom, not the cause + +The error surfaces in one place but originates in another. Swallowing an exception, clamping a bad value, or adding a null check where it crashes hides the failure without removing it — it reappears under a slightly different input. Trace back to where the wrong value was produced and fix it there. + +## Changing several things at once + +If you edit three things and the failure clears, you don't know which one mattered — and two of them may be new latent bugs. Change one thing, re-test, then the next. One variable per experiment. + +## Trusting an unconfirmed cause + +"It must be the cache" is a hypothesis, not a finding. Acting on a guess you haven't isolated wastes the edit and muddies the next diagnosis. Confirm the cause with a probe or a bisect before you fix it. + +## Not reproducing first + +Editing toward a failure you can't trigger means you can't tell when it's fixed. Get a reliable reproduction before touching code. + +## Assuming instead of reading + +"This function returns a list" — does it? Read the source with `read_file` and check the actual value with a probe rather than the value you expect. Most stubborn bugs live in the gap between assumption and reality. + +## Skipping verification + +A change that looks right is not a confirmed fix. Re-run the reproduction and watch it pass before claiming done. + +## Widening scope mid-debug + +Refactoring "while you're in there" mixes unrelated changes into the fix, making it harder to review and to bisect later. Fix the bug; note the cleanup separately. diff --git a/shellpilot/skills/builtin/debugging/references/method.md b/shellpilot/skills/builtin/debugging/references/method.md new file mode 100644 index 0000000..ff81da6 --- /dev/null +++ b/shellpilot/skills/builtin/debugging/references/method.md @@ -0,0 +1,27 @@ +# The Debugging Loop + +A repeatable order of operations. Each step gates the next — don't skip ahead. + +## 1. Reproduce + +Find the smallest, most reliable way to trigger the failure, and confirm it triggers now. Run the failing command, test, or input with `run_command` and capture the exact error. If you cannot reproduce it, you cannot know when it is fixed — gather the conditions (inputs, environment, recent changes) until it fails on demand. + +## 2. Hypothesize + +State one concrete, falsifiable guess about the cause: "the index is off by one", "the config isn't loaded before this call". Base it on the actual error text and the code you have read, not on a hunch. One hypothesis at a time — if you have several, rank them and test the cheapest first. + +## 3. Isolate + +Prove or disprove the hypothesis before changing code: + +- **Narrow the input** — strip the reproduction to the minimum that still fails. Each thing removed that doesn't change the failure is ruled out. +- **Bisect the change history** — if it worked before, find the commit that broke it. `git bisect` halves the search each step: mark a known-good and known-bad commit, test the midpoint it checks out, mark good or bad, repeat. The first bad commit names the change responsible. +- **Add a probe** — a log line, an assertion, or a `read_file` of intermediate state to confirm what the values actually are versus what you assumed. + +## 4. Fix the cause + +Change the root cause you isolated, not the symptom downstream of it. Make one change. A fix that suppresses the error message without addressing why it occurred will resurface elsewhere. + +## 5. Verify + +Re-run the exact reproduction from step 1 and confirm the failure is gone. Then run the surrounding tests or build to confirm you didn't break anything adjacent. A fix you haven't observed passing is a hypothesis, not a fix — see the `verification` skill. diff --git a/shellpilot/skills/builtin/git-workflow/SKILL.md b/shellpilot/skills/builtin/git-workflow/SKILL.md new file mode 100644 index 0000000..654e043 --- /dev/null +++ b/shellpilot/skills/builtin/git-workflow/SKILL.md @@ -0,0 +1,12 @@ +--- +name: git-workflow +description: Safe git practice — inspect before committing, atomic commits, clear history. +--- +Inspect before you commit: run `git status` and `git diff` and read them, so you commit what you intend and nothing stray. Keep commits atomic — one logical change each — with a message saying what changed and why. Work on a branch, and never rewrite history others may have pulled. + +In ShellPilot, `git reset`, `git clean`, branch deletion, and force-push are HIGH-risk and need explicit approval; a plain `git push` is medium. That gate is a backstop, not a substitute for checking the diff yourself. + +Two on-demand references — open with `skill_read(skill="git-workflow", resource="")`: + +- `commits` — staging deliberately and writing clear, atomic commit messages. +- `recovery` — undoing safely with reflog, reset, and restore. diff --git a/shellpilot/skills/builtin/git-workflow/references/commits.md b/shellpilot/skills/builtin/git-workflow/references/commits.md new file mode 100644 index 0000000..546d8ae --- /dev/null +++ b/shellpilot/skills/builtin/git-workflow/references/commits.md @@ -0,0 +1,23 @@ +# Staging and Commit Messages + +## Inspect first + +Before staging, run `git status` to see what changed and `git diff` to read the actual content. Read it — this is where you catch a debugging print, a secret, an unintended file, or a half-finished edit before it enters history. + +## Stage deliberately + +Add only the files that belong to this change. `git add ` over `git add -A` when the working tree holds unrelated edits, so each commit stays one logical change. Review staged content with `git diff --staged` before committing — the staged set is what gets recorded, not the working tree. + +## Atomic commits + +One commit, one logical change. A commit that fixes a bug *and* renames a module *and* tweaks formatting is hard to review, hard to revert, and useless to `git bisect`. If you did several things, split them. Each commit should leave the tree in a working state. + +## Writing the message + +- **Subject line:** imperative mood, ~50 characters, no trailing period — "Fix off-by-one in pager", not "Fixed the pager bug.". It completes the sentence "If applied, this commit will…". +- **Body** (after a blank line, wrapped ~72 cols): explain *why* the change was made and what it affects, not a line-by-line restatement of the diff — the diff already shows what. Note non-obvious consequences or trade-offs. +- Reference the issue or context if there is one. + +## Conventional commits + +Many projects prefix the subject with a type: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`. Match the repository's existing style — read recent `git log` before inventing a format. diff --git a/shellpilot/skills/builtin/git-workflow/references/recovery.md b/shellpilot/skills/builtin/git-workflow/references/recovery.md new file mode 100644 index 0000000..94d0fe7 --- /dev/null +++ b/shellpilot/skills/builtin/git-workflow/references/recovery.md @@ -0,0 +1,27 @@ +# Undoing Safely + +Git rarely loses committed work — the trick is knowing the right tool for the situation and preferring the non-destructive one. In ShellPilot, `git reset` and `git clean` are HIGH-risk and require explicit approval; treat that as a signal to be sure before you run them. + +## See where you've been: reflog + +`git reflog` lists every position HEAD has held — commits, resets, rebases, even "lost" ones. If you reset or rebased and want the old state back, find its hash in the reflog and recover it. This is the safety net behind most "I lost my commits" situations; check it before assuming work is gone. + +## Undo a commit without losing the work + +- `git revert ` — creates a *new* commit that undoes an earlier one. Safe on shared history because it adds rather than rewrites. Prefer this for anything already pushed. +- `git reset --soft HEAD~1` — moves the branch back one commit but keeps the changes staged. Good for "I committed too early". +- `git reset HEAD~1` (mixed, the default) — moves back one commit and unstages, keeping the file changes in the working tree. + +## Destructive — be sure first + +- `git reset --hard ` — discards commits *and* working-tree changes back to ``. Lost working changes are not in the reflog. Confirm `git status` is what you expect first. +- `git clean -fd` — deletes untracked files and directories permanently. Run `git clean -nd` first to preview exactly what would be removed. + +## Discard changes to a file + +- `git restore ` — discard unstaged changes to a tracked file (working tree back to the index). +- `git restore --staged ` — unstage a file, keeping its changes in the working tree. + +## Never rewrite shared history + +`git reset`, `git rebase`, `git commit --amend`, and force-push rewrite commits. That is fine on a local branch only you have. Once commits are pushed and others may have pulled them, rewriting forces everyone into a painful reconciliation — use `git revert` instead. ShellPilot classifies force-push as HIGH-risk for exactly this reason. diff --git a/shellpilot/skills/builtin/verification/SKILL.md b/shellpilot/skills/builtin/verification/SKILL.md new file mode 100644 index 0000000..3d9dff3 --- /dev/null +++ b/shellpilot/skills/builtin/verification/SKILL.md @@ -0,0 +1,9 @@ +--- +name: verification +description: Verify before claiming done — run the real check and observe the result. +--- +Do not report success you have not observed. Before saying a task is done, fixed, or passing, run the actual check — the test, the build, the command, the reproduction — with `run_command` and read its output. Compare what it produced against what you expected, not against what you assumed it would produce. "It should work" and "the code looks right" are not verification; a green result you watched is. + +If the check fails, the task is not done — fix it and re-run, don't soften the claim. + +One on-demand reference holds the per-change-type checklist — open it with `skill_read(skill="verification", resource="checklist")`: what counts as verified for code, a bug fix, a refactor, config, or docs. diff --git a/shellpilot/skills/builtin/verification/references/checklist.md b/shellpilot/skills/builtin/verification/references/checklist.md new file mode 100644 index 0000000..7bd1039 --- /dev/null +++ b/shellpilot/skills/builtin/verification/references/checklist.md @@ -0,0 +1,33 @@ +# Verification Checklist + +What "verified" means depends on what you changed. In every case the rule is the same: run the real check and observe the result before claiming done — never infer success from the diff. + +## New code or a feature + +- Run the test suite (or the specific tests covering the change) and confirm they pass. +- Run the build / type-check / lint that the project uses, and read the output for new errors. +- Exercise the new path at least once — call the function, hit the endpoint, run the command — and check it does what was asked, not just that it doesn't crash. + +## A bug fix + +- Re-run the exact reproduction that triggered the bug and confirm the failure is gone. A fix verified only by "the code now looks correct" is not verified. +- Run the surrounding tests to confirm you didn't break an adjacent case. +- If there was no failing test, the fix is incomplete until one exists that fails before the change and passes after. + +## A refactor + +- The behavior must be unchanged, so the existing tests must still pass with no edits. If you had to change a test's expectations, it wasn't a pure refactor — re-examine. +- Run the full relevant test set, not a subset; refactors break things at a distance. + +## Config or dependency change + +- Start the program / run the affected command and confirm it loads with the new config or version, rather than assuming the file is syntactically fine. +- Watch for warnings, not just errors. + +## Documentation + +- Render or read the result as a user would: check that code samples run, commands are copy-pasteable, and links resolve. Read it through once for accuracy against the implementation it describes. + +## Before you claim done + +State what you ran and what you saw — "ran `pytest`, 908 passed" — not "tests should pass". Evidence you observed, every time. diff --git a/shellpilot/skills/loader.py b/shellpilot/skills/loader.py index a7450fc..ad447e5 100644 --- a/shellpilot/skills/loader.py +++ b/shellpilot/skills/loader.py @@ -32,13 +32,17 @@ ] _BUILTIN_TRIGGER_MAP: dict[str, tuple[SkillTrigger, ...]] = { + "code-review": (SkillTrigger.ENABLED,), "context-management": (SkillTrigger.ALWAYS_ON,), + "debugging": (SkillTrigger.ENABLED,), + "git-workflow": (SkillTrigger.ENABLED,), "planning": ( SkillTrigger.PLAN_PROPOSED, SkillTrigger.PLAN_ACTIVE, SkillTrigger.PLAN_BLOCKED, ), "skill-authoring": (SkillTrigger.ENABLED,), + "verification": (SkillTrigger.ENABLED,), "web-grounding": (SkillTrigger.WEB_ENABLED,), } _PLANNING_REFERENCE_TRIGGER_MAP: dict[str, SkillTrigger] = { diff --git a/shellpilot/skills/model.py b/shellpilot/skills/model.py index 7f7982b..e4735f8 100644 --- a/shellpilot/skills/model.py +++ b/shellpilot/skills/model.py @@ -73,6 +73,6 @@ def __post_init__(self) -> None: def is_on_demand(resource: SkillResource) -> bool: - # ponytail: on_demand == no trigger today; the explicit `disclosure` dial + # NOTE: on_demand == no trigger today; the explicit `disclosure` dial # for per-model profiles replaces this when that consumer ships (v0.10.x). return resource.trigger is None diff --git a/shellpilot/tools/base.py b/shellpilot/tools/base.py index da4eea1..cd0ad41 100644 --- a/shellpilot/tools/base.py +++ b/shellpilot/tools/base.py @@ -167,3 +167,27 @@ def resolve_in_workspace(workspace: Path, raw_path: str) -> Path: f"path {raw_path} resolves outside the workspace boundary {root}" ) return resolved + + +# Honest marker shown instead of a path that resolves outside the workspace, so +# a spoofing argument never renders as a plausible in-workspace target. +OUTSIDE_WORKSPACE_DISPLAY = "" + + +def workspace_display(workspace: Path, raw_path: str) -> str: + """Faithful display form of a tool path argument (design section 14.5). + + NOTE (display-integrity invariant): the path shown to the user is + derived from the SAME ``resolve_in_workspace`` the tool acts on, so the + display can never diverge from the file actually touched. A spoofing + argument (``..`` segments, ``./x/../y``, symlink, trailing junk) collapses + to its resolved, workspace-relative target; an argument that escapes the + boundary renders as an honest rejection marker, never a fabricated path. + """ + try: + resolved = resolve_in_workspace(workspace, raw_path) + except WorkspaceBoundaryError: + return OUTSIDE_WORKSPACE_DISPLAY + relative = resolved.relative_to(workspace.resolve()) + rel_str = relative.as_posix() + return "." if rel_str == "." else rel_str diff --git a/shellpilot/tools/patch.py b/shellpilot/tools/patch.py index 43c62a3..993452f 100644 --- a/shellpilot/tools/patch.py +++ b/shellpilot/tools/patch.py @@ -20,7 +20,13 @@ from shellpilot.llm.messages import ToolDefinition from shellpilot.persistence.json_store import atomic_write_text from shellpilot.policy.risk import RiskLevel, SideEffect -from shellpilot.tools.base import ToolContext, ToolResult, ToolSpec, resolve_in_workspace +from shellpilot.tools.base import ( + ToolContext, + ToolResult, + ToolSpec, + resolve_in_workspace, + workspace_display, +) from shellpilot.tools.filesystem import ALL_PROFILES, is_binary OPERATIONS = ("replace_exact", "insert_before", "insert_after", "delete_exact") @@ -98,13 +104,20 @@ def _write_preserving(path: Path, text: str) -> None: os.chmod(path, mode) -def unified_diff(path: Path, before: str, after: str) -> str: +def unified_diff(display_path: str, before: str, after: str) -> str: + """Render a unified diff whose headers name *display_path*. + + NOTE (display-integrity invariant, design section 14.5): callers pass + the workspace-relative form of the SAME ``resolve_in_workspace`` result the + edit acts on, so the diff header — and the approval panel title derived from + it — always names the file actually written, never the raw model argument. + """ lines = list( difflib.unified_diff( before.splitlines(keepends=True), after.splitlines(keepends=True), - fromfile=f"a/{path.name}", - tofile=f"b/{path.name}", + fromfile=f"a/{display_path}", + tofile=f"b/{display_path}", ) ) if len(lines) > MAX_PREVIEW_LINES: @@ -127,7 +140,8 @@ def _patch_file(context: ToolContext, arguments: dict[str, Any]) -> ToolResult: _write_preserving(path, new_text) assert context.snapshots is not None context.snapshots.record(path, new_text.encode("utf-8")) - diff = unified_diff(path, text, new_text) + display = workspace_display(context.workspace, str(arguments["path"])) + diff = unified_diff(display, text, new_text) return ToolResult( success=True, summary=f"patched {arguments['path']} ({arguments['operation']})", @@ -150,7 +164,8 @@ def _patch_preview(context: ToolContext, arguments: dict[str, Any]) -> str: ) if new_text is None: return f"(cannot preview: {edit_error})" - return unified_diff(path, text, new_text) + display = workspace_display(context.workspace, str(arguments["path"])) + return unified_diff(display, text, new_text) def _write_file(context: ToolContext, arguments: dict[str, Any]) -> ToolResult: @@ -197,7 +212,7 @@ def _write_file(context: ToolContext, arguments: dict[str, Any]) -> ToolResult: _write_preserving(path, new_text) if context.snapshots is not None: context.snapshots.record(path, new_text.encode("utf-8")) - diff = unified_diff(path, before, new_text) + diff = unified_diff(workspace_display(context.workspace, raw_path), before, new_text) return ToolResult( success=True, summary=f"wrote {raw_path} ({mode}, {len(new_text)} chars)", @@ -222,7 +237,7 @@ def _write_preview(context: ToolContext, arguments: dict[str, Any]) -> str: else: before = path.read_bytes().decode("utf-8", errors="replace") after = before + content if mode == "append" else content - return unified_diff(path, before, after) + return unified_diff(workspace_display(context.workspace, raw_path), before, after) PATCH_FILE = ToolSpec( diff --git a/shellpilot/web/fetch.py b/shellpilot/web/fetch.py index 2e1730f..28438cc 100644 --- a/shellpilot/web/fetch.py +++ b/shellpilot/web/fetch.py @@ -11,14 +11,19 @@ request is issued, so a public URL that 302s to a private IP is blocked at the second hop before any connection is made. -Known limitation — DNS-based private-IP bypass ----------------------------------------------- -Guards are applied to the URL hostname before any network request. If a -public-looking DNS name resolves to a private/loopback IP (DNS rebinding), the -fetcher will not catch it. DNS pinning or post-connect IP inspection is not -implemented; the guards here cover the common/accidental cases, not adversarial -ones. Users who need stronger SSRF protection should layer this behind a -network-level egress filter or a dedicated DNS-pinning proxy. +DNS resolution +-------------- +Guards are applied before any request is issued. IP literals are validated +directly; DNS names are resolved (``getaddrinfo``) and every resulting address +is validated, so a public-looking name that resolves to a private/loopback/ +metadata IP (DNS rebinding) is blocked pre-request. + +Accepted residual — the connection is not pinned to the resolved IP (that would +break TLS SNI/cert verification for arbitrary HTTPS), so a name that resolves +public during the guard but private on the subsequent connect (the narrow +resolve→reconnect rebind window) is not caught. On a single-user box where +``web_fetch`` is opt-in and approval-gated with the hostname shown, this is +accepted; stronger protection belongs in a network-level egress filter. """ from __future__ import annotations @@ -55,14 +60,27 @@ class FetchedPage: truncated: bool # True when either byte cap or char cap was hit +def _ip_blocked(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + """Return True for any non-public address. + + ``is_global`` is False for loopback, private (RFC-1918), link-local, + reserved, unspecified, and shared CGNAT (100.64.0.0/10) ranges — a single + check covering every range a fetch must never reach. + """ + return not addr.is_global + + def _check_url(url: str) -> None: """Raise :class:`WebFetchError` if *url* fails pre-request guards. Checks (in order): 1. Scheme must be ``http`` or ``https``. 2. Hostname must not be empty. - 3. Literal IP addresses must not be loopback / private / link-local / - reserved. The hostname ``localhost`` and ``0.0.0.0`` are also blocked. + 3. Literal IP addresses must be public (not loopback / private / link-local + / reserved / CGNAT). The hostname ``localhost`` and ``0.0.0.0`` are also + blocked. + 4. DNS names are resolved and every resulting address must be public, so a + public-looking name that resolves to a non-public IP is blocked here. """ parts = urlsplit(url) @@ -96,18 +114,44 @@ def _check_url(url: str) -> None: packed = socket.inet_aton(ip_str) addr = ipaddress.IPv4Address(int.from_bytes(packed, "big")) except OSError: - # Not parseable by inet_aton either — treat as DNS name. + # Not parseable by inet_aton either — treat as a DNS name. + _check_resolved(hostname) return else: - # DNS name; DNS resolution is NOT checked here. + # DNS name — resolve and validate every address it points to. + _check_resolved(hostname) return - if addr.is_loopback or addr.is_private or addr.is_link_local or addr.is_reserved: + if _ip_blocked(addr): raise WebFetchError( - f"Fetching IP address {addr!s} is not allowed (loopback/private/link-local/reserved)." + f"Fetching IP address {addr!s} is not allowed (loopback/private/link-local/CGNAT)." ) +def _check_resolved(hostname: str) -> None: + """Resolve *hostname* and raise if any address it points to is non-public. + + An unresolvable name does not raise here — the subsequent connection attempt + will fail cleanly, and there is nothing to validate. The connection is not + pinned to the resolved IP, so the narrow resolve→reconnect rebind window is + an accepted residual (see the module docstring). + """ + try: + infos = socket.getaddrinfo(hostname, None) + except (socket.gaierror, UnicodeError): + # Unresolvable, or an un-encodable IDNA hostname: nothing to validate and + # no IP was reached. Fail closed — the connection attempt fails cleanly, + # and a raw UnicodeError never escapes the WebFetchError contract. + return + for info in infos: + sockaddr = info[4] + resolved = ipaddress.ip_address(sockaddr[0]) + if _ip_blocked(resolved): + raise WebFetchError( + f"{hostname!r} resolves to a non-public address ({resolved!s}); blocked." + ) + + def _content_type_accepted(content_type: str) -> bool: """Return True when the Content-Type contains an accepted token.""" ct_lower = content_type.lower() diff --git a/tests/fakes/fake_ui.py b/tests/fakes/fake_ui.py index bcdf924..a9d9a17 100644 --- a/tests/fakes/fake_ui.py +++ b/tests/fakes/fake_ui.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING +from shellpilot.policy.approvals import APPROVE, DECLINE, ApprovalReply + if TYPE_CHECKING: from shellpilot.policy.approvals import ApprovalRequest from shellpilot.runtime.events import TurnStats @@ -24,6 +26,9 @@ class FakeUI: command_lines: list[str] = field(default_factory=list) approval_requests: list[ApprovalRequest] = field(default_factory=list) approve_actions: bool = True + # When set, every approval is STEERED with this guidance (overrides + # approve_actions): the action is rejected and the text is fed to the model. + steer_text: str | None = None plan_approvals: list[tuple[str, str]] = field(default_factory=list) plan_answer: tuple[str, str] = ("y", "") plan_progress: list[list[str]] = field(default_factory=list) @@ -55,9 +60,11 @@ def show_tool_result(self, name: str, success: bool, summary: str) -> None: def show_command_output(self, line: str) -> None: self.command_lines.append(line) - def ask_approval(self, request: ApprovalRequest) -> bool: + def ask_approval(self, request: ApprovalRequest) -> ApprovalReply: self.approval_requests.append(request) - return self.approve_actions + if self.steer_text is not None: + return ApprovalReply(approved=False, steer_text=self.steer_text) + return APPROVE if self.approve_actions else DECLINE def ask_plan_approval(self, plan: TaskPlan, path: str) -> tuple[str, str]: self.plan_approvals.append((plan.task_id, path)) diff --git a/tests/test_agents_md.py b/tests/test_agents_md.py index 72dce68..1de42bf 100644 --- a/tests/test_agents_md.py +++ b/tests/test_agents_md.py @@ -1,8 +1,12 @@ """Tests for AGENTS.md behavior-instruction loading (design section 16, v1 scope).""" +import hashlib from pathlib import Path -from shellpilot.memory.agents_md import load_behavior_instructions +from shellpilot.memory.agents_md import ( + load_behavior_instructions, + project_agents_md_digest, +) def test_missing_files_load_as_none(tmp_path: Path) -> None: @@ -43,3 +47,66 @@ def test_oversized_instructions_are_truncated(tmp_path: Path) -> None: assert instructions.global_text is not None assert len(instructions.global_text) <= 100 * 4 + 50 # budget + truncation marker assert "truncated" in instructions.global_text + + +def test_project_digest_for_present_file(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + workspace.mkdir() + raw = "This repo uses pytest.\n" + (workspace / "AGENTS.md").write_text(raw, encoding="utf-8") + expected = hashlib.sha256(raw.encode("utf-8")).hexdigest() + assert project_agents_md_digest(workspace) == expected + + +def test_project_digest_none_for_absent_file(tmp_path: Path) -> None: + assert project_agents_md_digest(tmp_path / "missing") is None + + +def test_project_digest_none_for_empty_file(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + workspace.mkdir() + (workspace / "AGENTS.md").write_text(" \n\t ", encoding="utf-8") + assert project_agents_md_digest(workspace) is None + + +def test_project_digest_changes_when_content_changes(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + workspace.mkdir() + agents = workspace / "AGENTS.md" + agents.write_text("Original instructions.", encoding="utf-8") + first = project_agents_md_digest(workspace) + agents.write_text("Malicious instructions.", encoding="utf-8") + second = project_agents_md_digest(workspace) + assert first is not None + assert second is not None + assert first != second + + +def test_untrusted_project_skips_project_text(tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + (config_dir / "AGENTS.md").write_text("Always be concise.") + workspace = tmp_path / "ws" + workspace.mkdir() + (workspace / "AGENTS.md").write_text("This repo uses pytest.") + + instructions = load_behavior_instructions( + config_dir=config_dir, + workspace=workspace, + max_tokens=1500, + project_trusted=False, + ) + # Untrusted project AGENTS.md is not loaded; global remains. + assert instructions.project_text is None + assert instructions.global_text == "Always be concise." + + +def test_trusted_project_is_default(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + workspace.mkdir() + (workspace / "AGENTS.md").write_text("This repo uses pytest.") + + instructions = load_behavior_instructions( + config_dir=tmp_path / "config", workspace=workspace, max_tokens=1500 + ) + assert instructions.project_text == "This repo uses pytest." diff --git a/tests/test_audit.py b/tests/test_audit.py index 9c14f60..e070d16 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -1,6 +1,8 @@ """Tests for the JSONL audit log (design section 22).""" import json +import os +import stat from pathlib import Path from shellpilot.persistence.audit_store import AuditLogger @@ -182,3 +184,53 @@ def test_audit_events_carry_updated_workspace_after_set_workspace(tmp_path: Path # The subsequent user_turn event must also carry the new workspace. turn_event = next(e for e in events if e.get("event") == "user_turn") assert turn_event["workspace"] == str(new_ws) + + +# --------------------------------------------------------------------------- +# F13: at-rest file permissions — audit log must be 0600, parent dir 0700 +# --------------------------------------------------------------------------- + + +def test_audit_log_file_created_mode_0600(tmp_path: Path) -> None: + """A freshly created audit log file must have mode 0600 (owner-read/write only).""" + log_dir = tmp_path / "audit_dir" + logger = AuditLogger( + path=log_dir / "audit.jsonl", + session_id="s1", + workspace=tmp_path, + profile="balanced", + ) + logger.write("session_start") + file_mode = stat.S_IMODE(os.stat(log_dir / "audit.jsonl").st_mode) + assert file_mode == 0o600, f"expected 0o600, got {oct(file_mode)}" + + +def test_audit_log_parent_dir_created_mode_0700(tmp_path: Path) -> None: + """The parent directory created by AuditLogger.write must have mode 0700.""" + log_dir = tmp_path / "fresh_audit_dir" + logger = AuditLogger( + path=log_dir / "audit.jsonl", + session_id="s2", + workspace=tmp_path, + profile="balanced", + ) + logger.write("session_start") + dir_mode = stat.S_IMODE(os.stat(log_dir).st_mode) + assert dir_mode == 0o700, f"expected 0o700, got {oct(dir_mode)}" + + +def test_audit_log_append_preserves_content(tmp_path: Path) -> None: + """Appended lines must accumulate; 0600 write must not truncate existing content.""" + log_dir = tmp_path / "append_dir" + logger = AuditLogger( + path=log_dir / "audit.jsonl", + session_id="s3", + workspace=tmp_path, + profile="balanced", + ) + logger.write("first_event", idx=0) + logger.write("second_event", idx=1) + lines = (log_dir / "audit.jsonl").read_text(encoding="utf-8").splitlines() + assert len(lines) == 2 + assert json.loads(lines[0])["event"] == "first_event" + assert json.loads(lines[1])["event"] == "second_event" diff --git a/tests/test_banner.py b/tests/test_banner.py new file mode 100644 index 0000000..4bde9a9 --- /dev/null +++ b/tests/test_banner.py @@ -0,0 +1,183 @@ +"""Tests for shellpilot.cli.banner — boot-banner renderer.""" + +from rich.console import Console +from rich.panel import Panel + +from shellpilot.cli.banner import render_banner + +_JET_GLYPHS = ("▀", "▄", "█") + + +def _export(panel: Panel, *, styles: bool = False, width: int = 120) -> str: + console = Console(record=True, width=width, force_terminal=True) + console.print(panel) + return console.export_text(styles=styles) + + +def _jet_left_cells(text: str) -> list[str]: + """Left-column slice of each jet row, trailing space KEPT. + + Trailing whitespace is load-bearing for the symmetry check: the jet block + occupies a fixed-width field, and a centered row's right margin lives in + that trailing space. We slice between the panel's left border `│` and the + interior divider `│`, keeping everything (including spaces) in between. + """ + rows: list[str] = [] + for line in text.splitlines(): + if not any(g in line for g in _JET_GLYPHS): + continue + first = line.index("│") # left panel border + divider = line.index("│", first + 1) # interior column divider + rows.append(line[first + 1 : divider]) + return rows + + +def test_renders_without_error_local() -> None: + panel = render_banner("gemma4:e4b", is_cloud=False, profile="balanced") + assert isinstance(panel, Panel) + text = _export(panel) + assert "gemma4:e4b" in text + + +def test_renders_without_error_cloud() -> None: + panel = render_banner("nemotron-3-nano:30b-cloud", is_cloud=True, profile="balanced") + assert isinstance(panel, Panel) + text = _export(panel) + assert "nemotron-3-nano:30b-cloud" in text + + +def test_welcome_text_present() -> None: + panel = render_banner("gemma4:e4b", is_cloud=False, profile="balanced") + text = _export(panel) + assert "Welcome back, pilot" in text + + +def test_jet_glyph_present() -> None: + panel = render_banner("gemma4:e4b", is_cloud=False, profile="balanced") + text = _export(panel) + assert "█" in text + + +def test_subline_shows_profile_and_locality() -> None: + local = _export(render_banner("m", is_cloud=False, profile="balanced")) + assert "balanced · local" in local + cloud = _export(render_banner("m", is_cloud=True, profile="trusted")) + assert "trusted · cloud" in cloud + + +def test_command_section_present() -> None: + text = _export(render_banner("m", is_cloud=False, profile="balanced")) + assert "Commands" in text + assert "/help" in text + assert "/plan" in text + assert "/skills" in text + assert "/status" in text + + +def test_tips_section_present() -> None: + text = _export(render_banner("m", is_cloud=False, profile="balanced")) + assert "Tips" in text + assert "for slash commands" in text + assert "to run a shell command" in text + assert "to confirm a high-risk command" in text + + +def test_workflow_skills_section_enabled() -> None: + text = _export( + render_banner("m", is_cloud=False, profile="balanced", skills=("debugging", "verification")) + ) + assert "Workflow skills" in text + assert "debugging" in text + assert "verification" in text + # No enable hint when skills are already enabled. + assert "/skills to enable" not in text + + +def test_workflow_skills_section_hint_when_none_enabled() -> None: + text = _export(render_banner("m", is_cloud=False, profile="balanced", skills=())) + assert "Workflow skills" in text + assert "/skills to enable" in text + + +def test_recent_sessions_section_present_when_nonempty() -> None: + text = _export( + render_banner( + "m", + is_cloud=False, + profile="balanced", + recent_sessions=(("docs-pass", "39m ago"), ("try-auth-fix", "2h ago")), + ) + ) + assert "Recent sessions" in text + assert "docs-pass" in text + assert "39m ago" in text + assert "try-auth-fix" in text + + +def test_recent_sessions_section_omitted_when_empty() -> None: + text = _export(render_banner("m", is_cloud=False, profile="balanced", recent_sessions=())) + assert "Recent sessions" not in text + + +def test_version_title_present() -> None: + from shellpilot import __version__ + + text = _export(render_banner("m", is_cloud=False, profile="balanced")) + assert f"ShellPilot v{__version__}" in text + + +def test_local_vs_cloud_different_color() -> None: + local_styled = _export( + render_banner("testmodel", is_cloud=False, profile="balanced"), styles=True + ) + cloud_styled = _export( + render_banner("testmodel", is_cloud=True, profile="balanced"), styles=True + ) + # local uses green (#98c379), cloud uses amber (#e5c07b); styled export + # encodes ANSI color codes so we verify they differ. + assert local_styled != cloud_styled + + +def test_jet_is_left_right_symmetric() -> None: + """Regression: the jet must render symmetric (equal leading/trailing margin). + + The earlier per-line ``justify="center"`` implementation drifted because + Rich strips trailing whitespace per line, so narrower jet rows lost their + right margin and slid off-center. Each jet row must occupy a fixed-width + field with equal leading and trailing margin around the glyph span. + """ + text = _export(render_banner("gemma4:e4b", is_cloud=False, profile="balanced")) + rows = _jet_left_cells(text) + assert rows, "no jet rows found in render" + # The jet block is centered as a unit: every row shares the same fixed-width + # field, so the field's left edge is the minimum leading whitespace and its + # right edge the minimum trailing whitespace across all rows. + field_left = min(len(r) - len(r.lstrip(" ")) for r in rows) + field_right = min(len(r) - len(r.rstrip(" ")) for r in rows) + for raw in rows: + # Glyph span within the shared field. + first = min(raw.index(g) for g in _JET_GLYPHS if g in raw) + last = max(raw.rindex(g) for g in _JET_GLYPHS if g in raw) + leading = first - field_left + trailing = (len(raw) - field_right) - 1 - last + assert leading == trailing, ( + f"jet row not centered: leading={leading} trailing={trailing} :: {raw!r}" + ) + + +def test_recent_session_label_sanitized() -> None: + """A control/ANSI sequence in a recent-session label is stripped (Group B). + + The label is a snippet of a past session's first user message — untrusted, + possibly-pasted input. A stored escape (e.g. clear-screen) must not repaint + the terminal at boot. + """ + panel = render_banner( + "gemma4:e4b", + is_cloud=False, + profile="balanced", + recent_sessions=[("hi\x1b[2Jpwned", "1h ago")], + ) + out = _export(panel) # plain text; the label's ESC is the only escape source + assert "\x1b" not in out, "raw ESC from the session label must be stripped before render" + assert "pwned" in out, "the visible label text should survive, minus the escape byte" diff --git a/tests/test_command_policy.py b/tests/test_command_policy.py index 40846cc..c043fb8 100644 --- a/tests/test_command_policy.py +++ b/tests/test_command_policy.py @@ -18,8 +18,7 @@ (["git", "diff", "--stat"], RiskLevel.LOW), (["git", "log", "--oneline"], RiskLevel.LOW), (["grep", "-R", "foo", "."], RiskLevel.LOW), - (["python", "-m", "pytest"], RiskLevel.LOW), - (["pytest", "-q"], RiskLevel.LOW), + (["python", "--version"], RiskLevel.LOW), (["uname", "-a"], RiskLevel.LOW), # Medium: workspace writes, packages, network, commits (["rm", "file.txt"], RiskLevel.MEDIUM), @@ -57,6 +56,23 @@ (["cat", ".env"], RiskLevel.HIGH), ] +GIT_PRE_VERB_CASES: list[tuple[list[str], RiskLevel]] = [ + # Benign globals preserve read-only classification. + (["git", "--no-pager", "log"], RiskLevel.LOW), + (["git", "--literal-pathspecs", "diff"], RiskLevel.LOW), + # All other pre-verb globals are conservative, including recognized + # value-bearing options whose values must not be mistaken for verbs. + (["git", "--git-dir=/tmp/repo", "log"], RiskLevel.MEDIUM), + (["git", "--git-dir", "/tmp/repo", "log"], RiskLevel.MEDIUM), + (["git", "--work-tree", "../other", "log"], RiskLevel.MEDIUM), + (["git", "-C", "../other", "status"], RiskLevel.MEDIUM), + (["git", "-c", "core.pager=cat", "log"], RiskLevel.MEDIUM), + (["git", "-c=core.pager=cat", "log"], RiskLevel.MEDIUM), + (["git", "--future-global", "log"], RiskLevel.MEDIUM), + (["git", "--exec-path=/tmp", "log"], RiskLevel.MEDIUM), + (["git", "--git-dir", "reset", "log"], RiskLevel.MEDIUM), +] + @pytest.mark.parametrize(("argv", "expected"), CASES, ids=lambda case: str(case)) def test_classification_table(argv: list[str], expected: RiskLevel) -> None: @@ -64,6 +80,101 @@ def test_classification_table(argv: list[str], expected: RiskLevel) -> None: assert result.risk == expected, f"{argv}: {result.reasons}" +@pytest.mark.parametrize("argv", [["pytest", "-q"], ["python", "-m", "pytest"]]) +def test_pytest_requires_approval(argv: list[str]) -> None: + result = classify_command(argv, workspace=WS) + assert result.risk == RiskLevel.MEDIUM + + +def test_python_script_with_version_argument_requires_approval() -> None: + result = classify_command(["python", "script.py", "--version"], workspace=WS) + assert result.risk == RiskLevel.MEDIUM + + +@pytest.mark.parametrize( + ("argv", "expected"), + [ + (["grep", "pattern", "."], RiskLevel.LOW), + (["./grep", "pattern", "."], RiskLevel.MEDIUM), + (["/usr/bin/ls"], RiskLevel.MEDIUM), + (["/bin/rm", "-rf", "x"], RiskLevel.HIGH), + (["/usr/bin/sudo"], RiskLevel.HIGH), + (["/bin/cat", "/etc/passwd"], RiskLevel.HIGH), + ], +) +def test_path_qualified_executable_has_medium_floor(argv: list[str], expected: RiskLevel) -> None: + result = classify_command(argv, workspace=WS) + assert result.risk == expected, f"{argv}: {result.reasons}" + + +@pytest.mark.parametrize(("argv", "expected"), GIT_PRE_VERB_CASES, ids=lambda case: str(case)) +def test_git_pre_verb_global_classification(argv: list[str], expected: RiskLevel) -> None: + result = classify_command(argv, workspace=WS) + assert result.risk == expected, f"{argv}: {result.reasons}" + + +def test_git_benign_global_does_not_hide_destructive_verb() -> None: + result = classify_command(["git", "--no-pager", "reset"], workspace=WS) + assert result.risk == RiskLevel.HIGH + + +@pytest.mark.parametrize( + ("argv", "expected"), + [ + (["git", "branch", "-d", "topic"], RiskLevel.HIGH), + (["git", "branch", "-dr", "topic"], RiskLevel.HIGH), + (["git", "branch", "-rd", "topic"], RiskLevel.HIGH), + (["git", "branch", "--dele", "topic"], RiskLevel.HIGH), + (["git", "branch", "-r"], RiskLevel.LOW), + (["git", "branch", "--list", "topic"], RiskLevel.LOW), + ], +) +def test_git_branch_delete_forms_are_high(argv: list[str], expected: RiskLevel) -> None: + result = classify_command(argv, workspace=WS) + assert result.risk == expected, f"{argv}: {result.reasons}" + + +def test_git_bare_exec_path_does_not_treat_later_token_as_verb() -> None: + result = classify_command(["git", "--exec-path", "reset", "log"], workspace=WS) + assert result.risk == RiskLevel.MEDIUM + assert result.reasons == ("git uses a non-benign global option",) + + +@pytest.mark.parametrize( + "argv", + [ + ["git", "--no-pager", "stash", "list"], + ["git", "--literal-pathspecs", "stash", "show"], + ], +) +def test_git_benign_global_preserves_readonly_stash(argv: list[str]) -> None: + result = classify_command(argv, workspace=WS) + assert result.risk == RiskLevel.LOW + + +@pytest.mark.parametrize( + "argv", + [ + ["git", "push", "--force-with-lease=main:deadbeef"], + ["git", "push", "--force-w"], + ["git", "push", "--force-with-l"], + ["git", "push", "--force-with-l=main:deadbeef"], + ["git", "push", "origin", "+main"], + ["git", "push", "-f"], + ["git", "push", "-uf"], + ["git", "push", "-fu"], + ], +) +def test_git_force_push_forms_are_high(argv: list[str]) -> None: + result = classify_command(argv, workspace=WS) + assert result.risk == RiskLevel.HIGH + + +def test_git_push_option_value_containing_f_is_not_force() -> None: + result = classify_command(["git", "push", "-ofoo"], workspace=WS) + assert result.risk == RiskLevel.MEDIUM + + def test_write_outside_workspace_is_high() -> None: result = classify_command(["mv", "a.txt", "/etc/hosts"], workspace=WS) assert result.risk == RiskLevel.HIGH @@ -135,6 +246,77 @@ def test_ls_with_relative_escape_unchanged() -> None: # -- end relative-path boundary tests ------------------------------------------ +# -- reader-command workspace-boundary checks (F1 fix, v0.10.0) ---------------- + + +def test_cat_absolute_outside_is_high() -> None: + result = classify_command(["cat", "/etc/passwd"], workspace=WS) + assert result.risk == RiskLevel.HIGH + assert any("outside the workspace" in reason for reason in result.reasons) + + +def test_tail_relative_escape_is_high() -> None: + result = classify_command(["tail", "../sibling"], workspace=WS) + assert result.risk == RiskLevel.HIGH + assert any("outside the workspace" in reason for reason in result.reasons) + + +def test_grep_recursive_outside_is_high() -> None: + result = classify_command(["grep", "-r", "password", "/Users"], workspace=WS) + assert result.risk == RiskLevel.HIGH + assert any("outside the workspace" in reason for reason in result.reasons) + + +def test_head_absolute_outside_is_high() -> None: + result = classify_command(["head", "/tmp/x"], workspace=WS) + assert result.risk == RiskLevel.HIGH + assert any("outside the workspace" in reason for reason in result.reasons) + + +def test_cat_inside_workspace_stays_low() -> None: + result = classify_command(["cat", "./README.md"], workspace=WS) + assert result.risk == RiskLevel.LOW + + +def test_grep_recursive_inside_stays_low() -> None: + result = classify_command(["grep", "-r", "TODO", "."], workspace=WS) + assert result.risk == RiskLevel.LOW + + +def test_wc_relative_inside_stays_low() -> None: + result = classify_command(["wc", "-l", "src/foo.py"], workspace=WS) + assert result.risk == RiskLevel.LOW + + +def test_bare_ls_no_path_stays_low() -> None: + # ls is not a reader executable; no path arg, no boundary check + result = classify_command(["ls"], workspace=WS) + assert result.risk == RiskLevel.LOW + + +def test_cat_secret_marker_still_high() -> None: + # regression: marker-bearing reads stay HIGH via _touches_secret_path + result = classify_command(["cat", ".env"], workspace=WS) + assert result.risk == RiskLevel.HIGH + + +def test_rm_outside_behavior_unchanged_after_rename() -> None: + # regression guard for the _writes_outside_workspace -> _path_arg_outside_workspace rename + result = classify_command(["rm", "-rf", "/tmp/x"], workspace=WS) + assert result.risk == RiskLevel.HIGH + assert "recursive delete" in result.reasons + + +def test_mv_outside_behavior_unchanged_after_rename() -> None: + # regression guard for the rename: WRITE_COMMANDS branch reason unchanged + result = classify_command(["mv", "x", "/etc/y"], workspace=WS) + assert result.risk == RiskLevel.HIGH + assert any("outside the workspace boundary" in reason for reason in result.reasons) + + +# -- end reader-command boundary tests ---------------------------------------- + + def test_reasons_are_present_for_risky_commands() -> None: result = classify_command(["rm", "-rf", "build"], workspace=WS) assert result.reasons diff --git a/tests/test_config.py b/tests/test_config.py index df4bb06..5860ca5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -52,11 +52,15 @@ def test_env_overrides_project(tmp_path: Path) -> None: loaded = load_config( user_config_file=tmp_path / "missing.toml", project_config_file=project, + # SHELLPILOT_PROFILE is no longer mapped: the security posture is a + # config-file-only key, so an ambient env var must not set it. env={"SHELLPILOT_MODEL": "gemma4:e4b", "SHELLPILOT_PROFILE": "supervised"}, ) assert loaded.settings.model.default == "gemma4:e4b" assert loaded.sources["model.default"] == "env" - assert loaded.settings.runtime.security_profile == "supervised" + # The env var is ignored; the profile stays at its default. + assert loaded.settings.runtime.security_profile == "balanced" + assert loaded.sources["runtime.security_profile"] == "default" def test_cli_overrides_everything(tmp_path: Path) -> None: @@ -462,6 +466,21 @@ def test_override_beats_user_config(tmp_path: Path) -> None: def test_override_beats_project_config(tmp_path: Path) -> None: """An override value wins over the project config layer.""" + loaded = _cfg( + tmp_path, + {"model.default": "gemma4:e4b"}, + project_toml='[model]\ndefault = "gemma4:e2b"\n', + ) + assert loaded.settings.model.default == "gemma4:e4b" + assert loaded.sources["model.default"] == "set" + + +def test_override_security_profile_applies_over_project(tmp_path: Path) -> None: + """runtime.security_profile is a high-stakes overrides key: a persisted + override (written by a confirmed /config set) applies and outranks project. + + The overrides layer wins over the project layer (source is 'set'). + """ loaded = _cfg( tmp_path, {"runtime.security_profile": "supervised"}, @@ -469,6 +488,7 @@ def test_override_beats_project_config(tmp_path: Path) -> None: ) assert loaded.settings.runtime.security_profile == "supervised" assert loaded.sources["runtime.security_profile"] == "set" + assert not any("config-file only" in w for w in loaded.warnings) def test_env_beats_override(tmp_path: Path) -> None: @@ -513,9 +533,20 @@ def test_override_model_options_skipped_with_warning(tmp_path: Path) -> None: def test_override_enum_violation_skipped_with_warning(tmp_path: Path) -> None: """An invalid enum value in overrides is skipped; a warning names the key.""" + loaded = _cfg(tmp_path, {"workspace.boundary": "badvalue"}) + assert loaded.warnings + assert any("workspace.boundary" in w for w in loaded.warnings) + assert loaded.settings.workspace.boundary == "start_cwd" + + +def test_override_security_profile_invalid_value_self_heals(tmp_path: Path) -> None: + """An invalid runtime.security_profile override value is skipped-with-warning + by the normal enum validation (it is no longer short-circuited as + config-file-only); the default profile stands.""" loaded = _cfg(tmp_path, {"runtime.security_profile": "badprofile"}) assert loaded.warnings assert any("runtime.security_profile" in w for w in loaded.warnings) + assert not any("config-file only" in w for w in loaded.warnings) assert loaded.settings.runtime.security_profile == "balanced" @@ -603,11 +634,20 @@ def test_validate_override_float_string_coercion(tmp_path: Path) -> None: def test_validate_override_valid_enum(tmp_path: Path) -> None: """A valid enum value passes validation.""" - assert validate_override("runtime.security_profile", "supervised") == "supervised" + assert validate_override("privacy.allow_sensitive_reads", "never") == "never" def test_validate_override_bad_enum_raises(tmp_path: Path) -> None: """An invalid enum value raises ConfigError.""" + with pytest.raises(ConfigError): + validate_override("privacy.allow_sensitive_reads", "root") + + +def test_validate_override_security_profile_accepts(tmp_path: Path) -> None: + """runtime.security_profile is a high-stakes /config set key (confirm-gated + in the handler) — validate_override coerces a valid value and still rejects + an invalid enum value.""" + assert validate_override("runtime.security_profile", "supervised") == "supervised" with pytest.raises(ConfigError): validate_override("runtime.security_profile", "root") @@ -800,3 +840,262 @@ def test_validate_override_skills_enabled_raises(tmp_path: Path) -> None: """skills.enabled raises ConfigError (config-file only).""" with pytest.raises(ConfigError, match="skills.enabled"): validate_override("skills.enabled", ["x"]) + + +# --------------------------------------------------------------------------- +# Egress / safety keys (tools.web, model.base_url, runtime.security_profile, +# model.allow_cloud) are HIGH_STAKES_KEYS: settable via a confirm-gated +# /config set (so they apply through the overrides layer) and via config.toml, +# but never via an env var (the env invariant is load-bearing — see below). +# --------------------------------------------------------------------------- + + +def test_override_tools_web_applies(tmp_path: Path) -> None: + """tools.web set via a (confirmed) /config set applies through overrides.""" + loaded = _cfg(tmp_path, {"tools.web": True}) + assert not any("config-file only" in w for w in loaded.warnings) + assert loaded.settings.tools.web is True + assert loaded.sources["tools.web"] == "set" + + +def test_validate_override_tools_web_accepts(tmp_path: Path) -> None: + """tools.web coerces via /config set (high-stakes, confirm-gated in handler).""" + assert validate_override("tools.web", "true") is True + + +def test_override_base_url_applies(tmp_path: Path) -> None: + """model.base_url set via a (confirmed) /config set applies through overrides.""" + loaded = _cfg(tmp_path, {"model.base_url": "http://127.0.0.1:9999"}) + assert not any("config-file only" in w for w in loaded.warnings) + assert loaded.settings.model.base_url == "http://127.0.0.1:9999" + assert loaded.sources["model.base_url"] == "set" + + +def test_validate_override_base_url_accepts(tmp_path: Path) -> None: + """model.base_url coerces via /config set (high-stakes, confirm-gated).""" + assert validate_override("model.base_url", "http://127.0.0.1:9999") == "http://127.0.0.1:9999" + + +def test_env_base_url_ignored(tmp_path: Path) -> None: + """SHELLPILOT_OLLAMA_BASE_URL no longer redirects the endpoint via config.""" + loaded = load_config( + user_config_file=tmp_path / "missing.toml", + project_config_file=tmp_path / "missing.toml", + env={"SHELLPILOT_OLLAMA_BASE_URL": "http://evil.example"}, + ) + assert loaded.settings.model.base_url == "http://localhost:11434" + assert loaded.sources["model.base_url"] == "default" + + +def test_env_security_profile_ignored(tmp_path: Path) -> None: + """SHELLPILOT_PROFILE no longer downgrades the security profile via config.""" + loaded = load_config( + user_config_file=tmp_path / "missing.toml", + project_config_file=tmp_path / "missing.toml", + env={"SHELLPILOT_PROFILE": "supervised"}, + ) + assert loaded.settings.runtime.security_profile == "balanced" + assert loaded.sources["runtime.security_profile"] == "default" + + +# --- No-regression: config.toml (user/project layers) still apply all five --- + + +def test_tools_web_config_toml_still_applies(tmp_path: Path) -> None: + """tools.web in user config.toml still enables egress (source 'user').""" + user = write_toml(tmp_path / "user.toml", "[tools]\nweb = true\n") + loaded = load_config( + user_config_file=user, project_config_file=tmp_path / "missing.toml", env={} + ) + assert loaded.settings.tools.web is True + assert loaded.sources["tools.web"] == "user" + + +def test_base_url_config_toml_still_applies(tmp_path: Path) -> None: + """model.base_url in project config.toml still redirects (source 'project').""" + project = write_toml(tmp_path / "proj.toml", '[model]\nbase_url = "http://127.0.0.1:9999"\n') + loaded = load_config( + user_config_file=tmp_path / "missing.toml", project_config_file=project, env={} + ) + assert loaded.settings.model.base_url == "http://127.0.0.1:9999" + assert loaded.sources["model.base_url"] == "project" + + +def test_security_profile_config_toml_still_applies(tmp_path: Path) -> None: + """runtime.security_profile in user config.toml still applies (source 'user').""" + user = write_toml(tmp_path / "user.toml", '[runtime]\nsecurity_profile = "supervised"\n') + loaded = load_config( + user_config_file=user, project_config_file=tmp_path / "missing.toml", env={} + ) + assert loaded.settings.runtime.security_profile == "supervised" + assert loaded.sources["runtime.security_profile"] == "user" + + +# --- Unchanged messages for the originally config-file-only keys --- + + +def test_override_model_options_message_unchanged(tmp_path: Path) -> None: + """The model.options overrides warning is byte-identical after unification.""" + loaded = _cfg(tmp_path, {"model.options": {"temperature": 0.9}}) + assert ( + "overrides: model.options is config-file only and cannot be set " + "via overrides — entry ignored" + ) in loaded.warnings + + +def test_override_skills_enabled_message_unchanged(tmp_path: Path) -> None: + """The skills.enabled overrides warning is byte-identical after unification.""" + loaded = _cfg(tmp_path, {"skills.enabled": ["x"]}) + assert ( + "overrides: skills.enabled is config-file only and cannot be set " + "via overrides — entry ignored" + ) in loaded.warnings + + +def test_validate_override_model_options_message_unchanged(tmp_path: Path) -> None: + """The model.options /config set error is byte-identical after unification.""" + with pytest.raises( + ConfigError, + match="^model.options is config-file only and cannot be set via /config set$", + ): + validate_override("model.options", {"temperature": 0.5}) + + +def test_validate_override_skills_enabled_message_unchanged(tmp_path: Path) -> None: + """The skills.enabled /config set error is byte-identical after unification.""" + with pytest.raises( + ConfigError, + match="^skills.enabled is config-file only and cannot be set via /config set$", + ): + validate_override("skills.enabled", ["x"]) + + +# --------------------------------------------------------------------------- +# model.allow_cloud: master cloud-egress switch (high-stakes, v0.10.0). +# Settable via confirm-gated /config set + config.toml; never via env var. +# --------------------------------------------------------------------------- + + +def test_allow_cloud_defaults_off(tmp_path: Path) -> None: + """allow_cloud is False by default — local-first egress stays disabled.""" + loaded = load_config( + user_config_file=tmp_path / "missing-user.toml", + project_config_file=tmp_path / "missing-project.toml", + env={}, + ) + assert loaded.settings.model.allow_cloud is False + assert loaded.sources["model.allow_cloud"] == "default" + + +def test_allow_cloud_toml_override(tmp_path: Path) -> None: + """allow_cloud = true in config.toml flips it on and records the source.""" + user = write_toml(tmp_path / "user.toml", "[model]\nallow_cloud = true\n") + loaded = load_config( + user_config_file=user, project_config_file=tmp_path / "missing.toml", env={} + ) + assert loaded.settings.model.allow_cloud is True + assert loaded.sources["model.allow_cloud"] == "user" + + +def test_allow_cloud_rejects_non_boolean(tmp_path: Path) -> None: + """allow_cloud must be a boolean; a string value is a fatal config error.""" + user = write_toml(tmp_path / "user.toml", '[model]\nallow_cloud = "yes"\n') + with pytest.raises(ConfigError, match="model.allow_cloud"): + load_config( + user_config_file=user, + project_config_file=tmp_path / "missing.toml", + env={}, + ) + + +def test_override_allow_cloud_applies(tmp_path: Path) -> None: + """allow_cloud set via a (confirmed) /config set applies through overrides. + + The per-session cloud-consent gate (§15.2) still fires regardless of how + allow_cloud was set, so this opens no silent-egress vector. + """ + loaded = _cfg(tmp_path, {"model.allow_cloud": True}) + assert not any("config-file only" in w for w in loaded.warnings) + assert loaded.settings.model.allow_cloud is True + assert loaded.sources["model.allow_cloud"] == "set" + + +def test_validate_override_allow_cloud_accepts(tmp_path: Path) -> None: + """allow_cloud coerces via /config set (high-stakes, confirm-gated).""" + assert validate_override("model.allow_cloud", "true") is True + + +def test_allow_cloud_has_no_env_var(tmp_path: Path) -> None: + """No env var can enable cloud egress (allow_cloud is absent from ENV_MAP).""" + from shellpilot.config.loader import ENV_MAP + + assert "model.allow_cloud" not in ENV_MAP.values() + + +def test_allow_cloud_is_boot_only(tmp_path: Path) -> None: + """allow_cloud is a boot-only key (cannot toggle egress mid-session).""" + from shellpilot.config.loader import BOOT_ONLY_KEYS + + assert "model.allow_cloud" in BOOT_ONLY_KEYS + + +def test_high_stakes_keys_membership() -> None: + """HIGH_STAKES_KEYS holds exactly the four egress/safety keys, and the two + structural keys stay config-file-only and out of the high-stakes set.""" + from shellpilot.config.loader import CONFIG_FILE_ONLY_KEYS, HIGH_STAKES_KEYS + + assert HIGH_STAKES_KEYS == { + "tools.web", + "model.base_url", + "runtime.security_profile", + "model.allow_cloud", + } + assert CONFIG_FILE_ONLY_KEYS == {"model.options", "skills.enabled"} + assert HIGH_STAKES_KEYS.isdisjoint(CONFIG_FILE_ONLY_KEYS) + + +def test_high_stakes_keys_absent_from_env_and_boot_only_except_live_profile() -> None: + """The load-bearing env invariant: every high-stakes key stays absent from + ENV_MAP (no ambient var can enable egress / downgrade the profile). The three + egress keys are boot-only; runtime.security_profile is the exception — it is + read per turn, so /config set lowers it this session (same live effect as + /profile use) and it is deliberately NOT in BOOT_ONLY_KEYS.""" + from shellpilot.config.loader import BOOT_ONLY_KEYS, ENV_MAP, HIGH_STAKES_KEYS + + for key in HIGH_STAKES_KEYS: + assert key not in ENV_MAP.values(), key + assert "runtime.security_profile" not in BOOT_ONLY_KEYS + for key in HIGH_STAKES_KEYS - {"runtime.security_profile"}: + assert key in BOOT_ONLY_KEYS, key + + +def test_is_cloud_model_classification() -> None: + """is_cloud_model recognises the Ollama cloud tag: ':cloud' and '-cloud'.""" + from shellpilot.config.model import is_cloud_model + + # Ollama tags cloud models two ways; missing either form would be a silent + # egress (the model leaves the device while classed as local). + assert is_cloud_model("nemotron-3-super:cloud") is True # un-sized ':cloud' + assert is_cloud_model("nemotron-3-nano:30b-cloud") is True # sized '-cloud' + assert is_cloud_model("gpt-oss:120b-cloud") is True + assert is_cloud_model("gemma4:e4b") is False + assert is_cloud_model("qwen3.5:4b-mlx") is False + assert is_cloud_model("") is False + # The cloud marker is the TAG, not the model name: 'cloud-runner' is local. + assert is_cloud_model("cloud-runner:7b") is False + + +def test_is_egressing_combines_cloud_model_and_base_url() -> None: + """is_egressing is True for a cloud model OR a non-loopback endpoint.""" + from shellpilot.config.model import is_egressing + + loopback = "http://127.0.0.1:11434" + remote = "http://10.0.0.5:11434" + # Cloud model on a loopback endpoint still egresses (Ollama proxies it out). + assert is_egressing("x-cloud", loopback) is True + # A local model on a loopback endpoint stays local. + assert is_egressing("gemma4:e4b", loopback) is False + # A local model pointed at a non-loopback endpoint egresses. + assert is_egressing("gemma4:e4b", remote) is True + # Both signals firing is still egressing. + assert is_egressing("x-cloud", remote) is True diff --git a/tests/test_conversation.py b/tests/test_conversation.py index 4db6b86..5e7c4d5 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -3,7 +3,13 @@ import json from pathlib import Path -from shellpilot.config.model import ContextSettings, Settings, SkillSettings, ToolSettings +from shellpilot.config.model import ( + ContextSettings, + PrivacySettings, + Settings, + SkillSettings, + ToolSettings, +) from shellpilot.llm.messages import Message from shellpilot.memory.agents_md import BehaviorInstructions from shellpilot.persistence.audit_store import AuditLogger @@ -845,3 +851,300 @@ def test_skill_read_registered_when_skills_enabled(tmp_path: Path) -> None: settings = Settings(skills=SkillSettings(enabled=("skill-authoring",))) runtime = make_runtime(fake, FakeUI(), tmp_path, settings=settings) assert runtime.registry.get("skill_read") is not None + + +# --------------------------------------------------------------------------- +# Group E: egress chokepoint (locality signal, outbound redaction, model_request) +# --------------------------------------------------------------------------- + + +def _make_runtime_with_base_url( + fake: FakeLLM, + ui: FakeUI, + tmp_path: Path, + base_url: str, + *, + audit: AuditLogger | None = None, + settings: Settings | None = None, +) -> ConversationRuntime: + return ConversationRuntime( + llm=fake, + settings=settings or Settings(), + workspace=tmp_path, + behavior=BehaviorInstructions(global_text=None, project_text=None), + ui=ui, + base_url=base_url, + audit=audit, + ) + + +def test_endpoint_loopback_detection_local(tmp_path: Path) -> None: + """localhost / 127.x / ::1 / 0.0.0.0 / empty host are loopback (not egressing).""" + for url in ( + "http://localhost:11434", + "http://127.0.0.1:11434", + "http://127.5.6.7:11434", + "http://[::1]:11434", + "http://0.0.0.0:11434", + "http://foo.localhost:11434", + ): + runtime = _make_runtime_with_base_url(FakeLLM(script=[]), FakeUI(), tmp_path, url) + assert runtime._endpoint_is_loopback() is True, url + assert runtime._is_egressing() is False, url + + +def test_endpoint_loopback_detection_remote(tmp_path: Path) -> None: + """A non-loopback host is remote → egressing.""" + for url in ( + "https://ollama.com", + "https://api.example.com:443", + "http://10.0.0.5:11434", # private but not loopback → still off this box + ): + runtime = _make_runtime_with_base_url(FakeLLM(script=[]), FakeUI(), tmp_path, url) + assert runtime._endpoint_is_loopback() is False, url + assert runtime._is_egressing() is True, url + + +def test_default_base_url_is_loopback(tmp_path: Path) -> None: + """The default constructor (no base_url) is loopback — zero behaviour change.""" + runtime = make_runtime(FakeLLM(script=[]), FakeUI(), tmp_path) + assert runtime._is_egressing() is False + + +def test_outbound_redaction_on_remote_turn(tmp_path: Path) -> None: + """A secret in history is redacted in the messages handed to chat() on a remote turn.""" + secret = "AKIAIOSFODNN7EXAMPLE" + fake = FakeLLM(script=[answer("done")]) + runtime = _make_runtime_with_base_url(fake, FakeUI(), tmp_path, "https://ollama.com") + # Seed history with a tool result carrying a secret. + from shellpilot.llm.messages import tool_result + + runtime._history.append(tool_result(f"key is {secret}")) + + runtime.run_turn("summarize") + + sent = fake.calls[0].messages + joined = "\n".join(m.content for m in sent) + assert secret not in joined + assert "[REDACTED]" in joined + # History itself is never mutated. + assert any(secret in m.content for m in runtime._history) + + +def test_loopback_turn_messages_byte_identical(tmp_path: Path) -> None: + """On a loopback turn the messages are passed unchanged — redaction is never invoked.""" + secret = "AKIAIOSFODNN7EXAMPLE" + fake = FakeLLM(script=[answer("done")]) + runtime = make_runtime(fake, FakeUI(), tmp_path) + from shellpilot.llm.messages import tool_result + + runtime._history.append(tool_result(f"key is {secret}")) + + # Positively pin the no-copy path: the egress redaction helper must not run + # on a loopback turn (the remote test proves it DOES run when egressing). + called = False + original = runtime._redacted_for_egress + + def _spy(messages): + nonlocal called + called = True + return original(messages) + + runtime._redacted_for_egress = _spy + + runtime.run_turn("summarize") + + assert called is False # loopback never invokes outbound redaction + sent = fake.calls[0].messages + joined = "\n".join(m.content for m in sent) + assert secret in joined # not redacted — byte-identical + assert any(secret in m.content for m in runtime._history) + + +def test_outbound_redaction_disabled_when_privacy_off(tmp_path: Path) -> None: + """redact_secrets=False → even remote turns are not redacted.""" + secret = "AKIAIOSFODNN7EXAMPLE" + fake = FakeLLM(script=[answer("done")]) + settings = Settings(privacy=PrivacySettings(redact_secrets=False)) + runtime = _make_runtime_with_base_url( + fake, FakeUI(), tmp_path, "https://ollama.com", settings=settings + ) + from shellpilot.llm.messages import tool_result + + runtime._history.append(tool_result(f"key is {secret}")) + + runtime.run_turn("summarize") + + joined = "\n".join(m.content for m in fake.calls[0].messages) + assert secret in joined + + +def test_model_request_audit_on_remote_turn(tmp_path: Path) -> None: + """A remote turn writes a model_request event with host/model/counts and NO body.""" + audit = AuditLogger( + path=tmp_path / "audit.jsonl", + session_id="sess-egress", + workspace=tmp_path, + profile="balanced", + ) + fake = FakeLLM(script=[answer("done")]) + runtime = _make_runtime_with_base_url( + fake, FakeUI(), tmp_path, "https://ollama.com", audit=audit + ) + from shellpilot.llm.messages import tool_result + + runtime._history.append(tool_result("some prompt body text here")) + + runtime.run_turn("hello") + + events = [json.loads(line) for line in (tmp_path / "audit.jsonl").read_text().splitlines()] + reqs = [e for e in events if e["event"] == "model_request"] + assert len(reqs) == 1 + ev = reqs[0] + assert ev["host"] == "ollama.com" + assert ev["model"] == "gemma4:e4b" + assert ev["locality"] == "remote" + assert ev["message_count"] >= 1 + assert ev["approx_bytes"] > 0 + assert ev["image_count"] == 0 + # No message body is recorded under any field. + assert "some prompt body text here" not in json.dumps(ev) + assert "hello" not in json.dumps(ev) + + +def test_no_model_request_audit_on_loopback_turn(tmp_path: Path) -> None: + """A loopback turn writes no model_request event.""" + audit = AuditLogger( + path=tmp_path / "audit.jsonl", + session_id="sess-local", + workspace=tmp_path, + profile="balanced", + ) + fake = FakeLLM(script=[answer("done")]) + runtime = _make_runtime_with_audit(fake, FakeUI(), tmp_path, audit) + runtime.run_turn("hello") + + events = [json.loads(line) for line in (tmp_path / "audit.jsonl").read_text().splitlines()] + assert not any(e["event"] == "model_request" for e in events) + + +# --------------------------------------------------------------------------- +# Shared loopback helper + cloud-model egress (v0.10.0 Part 2) +# --------------------------------------------------------------------------- + + +def test_is_loopback_url_shared_helper() -> None: + """The module-level helper classifies loopback vs remote URLs consistently.""" + from shellpilot.llm.ollama import is_loopback_url + + for url in ( + "", + "http://localhost:11434", + "http://127.0.0.1:11434", + "http://127.5.6.7:11434", + "http://[::1]:11434", + "http://0.0.0.0:11434", + "http://foo.localhost:11434", + ): + assert is_loopback_url(url) is True, url + for url in ( + "https://ollama.com", + "https://api.example.com:443", + "http://10.0.0.5:11434", + "ollama.com:443", # scheme-less, no parseable host → fail closed (remote) + "http://[bad", # unparseable URL → fail closed (remote), no raise + ): + assert is_loopback_url(url) is False, url + + +def test_endpoint_is_loopback_uses_shared_helper(tmp_path: Path) -> None: + """_endpoint_is_loopback delegates to the shared helper (no behaviour change).""" + runtime = _make_runtime_with_base_url( + FakeLLM(script=[]), FakeUI(), tmp_path, "https://ollama.com" + ) + assert runtime._endpoint_is_loopback() is False + + +def _make_runtime_with_model( + fake: FakeLLM, ui: FakeUI, tmp_path: Path, model: str, *, base_url: str +) -> ConversationRuntime: + return ConversationRuntime( + llm=fake, + settings=Settings(), + workspace=tmp_path, + behavior=BehaviorInstructions(global_text=None, project_text=None), + ui=ui, + model=model, + base_url=base_url, + ) + + +def test_cloud_model_egresses_on_localhost(tmp_path: Path) -> None: + """A '-cloud' model egresses even through a loopback Ollama proxy.""" + runtime = _make_runtime_with_model( + FakeLLM(script=[]), + FakeUI(), + tmp_path, + "nemotron-3-nano:30b-cloud", + base_url="http://localhost:11434", + ) + assert runtime._endpoint_is_loopback() is True + assert runtime._is_egressing() is True + + +def test_local_model_on_localhost_does_not_egress(tmp_path: Path) -> None: + """A local model on a loopback endpoint does not egress (the common path).""" + runtime = _make_runtime_with_model( + FakeLLM(script=[]), + FakeUI(), + tmp_path, + "gemma4:e4b", + base_url="http://localhost:11434", + ) + assert runtime._is_egressing() is False + + +# --------------------------------------------------------------------------- +# System-prompt honesty (v0.10.0 Part 2): the "no network" claim is conditional +# --------------------------------------------------------------------------- + + +def test_local_system_prompt_is_byte_identical(tmp_path: Path) -> None: + """A non-egressing (local) session's system prompt is unchanged — zero regression.""" + from shellpilot.prompts.system import build_system_prompt + + runtime = make_runtime(FakeLLM(script=[]), FakeUI(), tmp_path) + expected = build_system_prompt( + workspace=tmp_path, + profile="balanced", + ) + assert runtime._context_snapshot().system_text().startswith(expected) + assert "no independent network access" in runtime._system_message_text() + assert "entirely on this machine" in runtime._system_message_text() + + +def test_egressing_system_prompt_drops_false_network_claim(tmp_path: Path) -> None: + """An egressing session's prompt drops the false 'entirely on this machine' claim.""" + runtime = _make_runtime_with_model( + FakeLLM(script=[]), + FakeUI(), + tmp_path, + "nemotron-3-nano:30b-cloud", + base_url="http://localhost:11434", + ) + text = runtime._system_message_text() + assert "no independent network access" not in text + assert "entirely on this machine" not in text + assert "leaves this device" in text + + +def test_build_system_prompt_egressing_flag() -> None: + """build_system_prompt(is_egressing=True) replaces the local-only network line.""" + from shellpilot.prompts.system import build_system_prompt + + local = build_system_prompt(workspace=Path("/work"), profile="balanced") + remote = build_system_prompt(workspace=Path("/work"), profile="balanced", is_egressing=True) + assert "no independent network access" in local + assert "no independent network access" not in remote + assert "entirely on this machine" not in remote + assert "leaves this device" in remote diff --git a/tests/test_executor.py b/tests/test_executor.py index 40dc216..b2a16a1 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -8,6 +8,8 @@ from shellpilot.config.model import RuntimeSettings, Settings from shellpilot.llm.messages import ToolCall, ToolDefinition from shellpilot.memory.agents_md import BehaviorInstructions +from shellpilot.persistence.snapshots import SnapshotStore +from shellpilot.policy.approvals import APPROVE, DECLINE, ApprovalReply, ApprovalRequest from shellpilot.policy.risk import RiskLevel, SideEffect from shellpilot.runtime.conversation import ConversationRuntime from shellpilot.runtime.executor import ToolExecutor @@ -188,7 +190,7 @@ def test_precheck_failure_returns_failed_result_without_approval(tmp_path: Path) asker must never be invoked.""" approval_called = False - def _never_ask(request: Any) -> bool: + def _never_ask(request: Any) -> Any: nonlocal approval_called approval_called = True pytest.fail("approval asker must not be called when precheck fails") @@ -549,3 +551,308 @@ def test_enum_violation_increments_malformed_counter_and_sends_schema_reminder( # Schema reminder must have been sent. tool_messages = [m for m in fake.calls[-1].messages if m.role == "tool"] assert any("patch_file(" in m.content or "write_file(" in m.content for m in tool_messages) + + +# --------------------------------------------------------------------------- +# Group E (E4): web_egress audit for NETWORK-side-effect tools +# --------------------------------------------------------------------------- + + +def test_network_tool_writes_web_egress_audit(tmp_path: Path) -> None: + """A NETWORK-side-effect tool that runs writes a web_egress audit event + recording the tool and its (redacted) args, but no off-box fallback.""" + import json + + from shellpilot.persistence.audit_store import AuditLogger + + audit = AuditLogger( + path=tmp_path / "audit.jsonl", + session_id="sess-web", + workspace=tmp_path, + profile="balanced", + ) + + net_spec = ToolSpec( + definition=ToolDefinition( + name="web_search", + description="net tool", + parameters={"query": {"type": "string"}}, + required=("query",), + ), + side_effect=SideEffect.NETWORK, + default_risk=RiskLevel.MEDIUM, + allowed_profiles=frozenset({"supervised", "balanced"}), + handler=lambda ctx, args: ToolResult(success=True, summary="ok", content="results"), + ) + registry = ToolRegistry() + registry.register(net_spec) + executor = ToolExecutor( + registry=registry, + workspace=tmp_path, + profile="balanced", + max_result_tokens=2000, + max_total_tokens=10_000, + ask_approval=lambda req: APPROVE, # approve so the tool runs + audit=audit, + ) + + executor.execute(ToolCall(name="web_search", arguments={"query": "python release"})) + + events = [json.loads(line) for line in (tmp_path / "audit.jsonl").read_text().splitlines()] + egress = [e for e in events if e["event"] == "web_egress"] + assert len(egress) == 1 + assert egress[0]["tool"] == "web_search" + assert "python release" in json.dumps(egress[0]) + + +def test_no_web_egress_for_non_network_tool(tmp_path: Path) -> None: + """A NONE-side-effect tool writes no web_egress event.""" + import json + + from shellpilot.persistence.audit_store import AuditLogger + + audit = AuditLogger( + path=tmp_path / "audit.jsonl", + session_id="sess-local-tool", + workspace=tmp_path, + profile="balanced", + ) + spec = _make_spec() + registry = ToolRegistry() + registry.register(spec) + executor = ToolExecutor( + registry=registry, + workspace=tmp_path, + profile="balanced", + max_result_tokens=2000, + max_total_tokens=10_000, + audit=audit, + ) + + executor.execute(ToolCall(name="dummy", arguments={"x": "hello"})) + + text = (tmp_path / "audit.jsonl").read_text() if (tmp_path / "audit.jsonl").is_file() else "" + events = [json.loads(line) for line in text.splitlines()] if text else [] + assert not any(e["event"] == "web_egress" for e in events) + + +def test_no_web_egress_when_network_tool_declined(tmp_path: Path) -> None: + """A declined NETWORK tool never ran → no web_egress event (egress recorded + only when the call actually leaves the box).""" + import json + + from shellpilot.persistence.audit_store import AuditLogger + + audit = AuditLogger( + path=tmp_path / "audit.jsonl", + session_id="sess-declined", + workspace=tmp_path, + profile="balanced", + ) + net_spec = ToolSpec( + definition=ToolDefinition( + name="web_fetch", + description="net tool", + parameters={"url": {"type": "string"}}, + required=("url",), + ), + side_effect=SideEffect.NETWORK, + default_risk=RiskLevel.MEDIUM, + allowed_profiles=frozenset({"supervised", "balanced"}), + handler=lambda ctx, args: ToolResult(success=True, summary="ok", content="page"), + ) + registry = ToolRegistry() + registry.register(net_spec) + executor = ToolExecutor( + registry=registry, + workspace=tmp_path, + profile="balanced", + max_result_tokens=2000, + max_total_tokens=10_000, + ask_approval=lambda req: DECLINE, # decline + audit=audit, + ) + + executor.execute(ToolCall(name="web_fetch", arguments={"url": "https://example.com"})) + + text = (tmp_path / "audit.jsonl").read_text() if (tmp_path / "audit.jsonl").is_file() else "" + events = [json.loads(line) for line in text.splitlines()] if text else [] + assert not any(e["event"] == "web_egress" for e in events) + + +# --------------------------------------------------------------------------- +# Reject-and-steer (design section 14): the [e]dit approval outcome rejects the +# proposed action (NEVER runs it) and feeds the user's guidance back to the +# model so it re-proposes a corrected call through the normal gate. +# --------------------------------------------------------------------------- + + +def _side_effect_spec(name: str = "writer") -> tuple[ToolSpec, list[bool]]: + """A side-effecting spec plus a list that records whether its handler ran.""" + ran: list[bool] = [] + + def _handler(ctx: Any, args: Any) -> ToolResult: + ran.append(True) + return ToolResult(success=True, summary="wrote", content="") + + spec = ToolSpec( + definition=ToolDefinition( + name=name, + description="side-effecting tool", + parameters={"x": {"type": "string"}}, + required=("x",), + ), + side_effect=SideEffect.WORKSPACE_WRITE, + default_risk=RiskLevel.MEDIUM, + allowed_profiles=frozenset({"supervised", "balanced"}), + handler=_handler, + ) + return spec, ran + + +def test_steer_does_not_run_the_action(tmp_path: Path) -> None: + """[e]dit/STEER rejects the proposed action: the handler NEVER runs.""" + spec, ran = _side_effect_spec() + executor = _make_executor( + spec, + tmp_path, + ask_approval=lambda req: ApprovalReply(approved=False, steer_text="do X instead"), + ) + + outcome = executor.execute(ToolCall(name="writer", arguments={"x": "v"})) + + assert ran == [] # handler never invoked + assert outcome.result is not None + assert not outcome.result.success + + +def test_steer_guidance_reaches_the_model(tmp_path: Path) -> None: + """The user's guidance text is carried in the model-facing outcome.""" + spec, _ = _side_effect_spec() + executor = _make_executor( + spec, + tmp_path, + ask_approval=lambda req: ApprovalReply( + approved=False, steer_text="the dir is 'build' not 'bulid', use git clean" + ), + ) + + outcome = executor.execute(ToolCall(name="writer", arguments={"x": "v"})) + + assert "the dir is 'build' not 'bulid', use git clean" in outcome.model_text + # The model is told to propose a corrected action (not "do not retry"). + assert "Do not retry" not in outcome.model_text + + +def test_plain_decline_unchanged_with_new_reply_type(tmp_path: Path) -> None: + """A plain decline (no steer text) keeps the existing do-not-retry feedback.""" + spec, ran = _side_effect_spec() + executor = _make_executor(spec, tmp_path, ask_approval=lambda req: DECLINE) + + outcome = executor.execute(ToolCall(name="writer", arguments={"x": "v"})) + + assert ran == [] + assert "declined" in outcome.model_text + assert "Do not retry" in outcome.model_text + + +def test_steer_audit_decision_is_steered(tmp_path: Path) -> None: + """A steered approval is audited with decision=steered.""" + import json + + from shellpilot.persistence.audit_store import AuditLogger + + audit = AuditLogger( + path=tmp_path / "audit.jsonl", + session_id="sess-steer", + workspace=tmp_path, + profile="supervised", + ) + spec, _ = _side_effect_spec() + registry = ToolRegistry() + registry.register(spec) + executor = ToolExecutor( + registry=registry, + workspace=tmp_path, + profile="supervised", + max_result_tokens=2000, + max_total_tokens=10_000, + ask_approval=lambda req: ApprovalReply(approved=False, steer_text="do X instead"), + audit=audit, + ) + + executor.execute(ToolCall(name="writer", arguments={"x": "v"})) + + events = [json.loads(line) for line in (tmp_path / "audit.jsonl").read_text().splitlines()] + approvals = [e for e in events if e["event"] == "approval"] + assert len(approvals) == 1 + assert approvals[0]["decision"] == "steered" + + +# --------------------------------------------------------------------------- +# Display integrity: the approval panel shows the RESOLVED action path, never +# the raw (potentially spoofing) model argument (design sections 14.5, 36). +# --------------------------------------------------------------------------- + + +def _capture_request(spec: ToolSpec, tmp_path: Path, call: ToolCall) -> ApprovalRequest: + """Run a call through the executor and return the ApprovalRequest it built.""" + captured: list[ApprovalRequest] = [] + + def _ask(request: ApprovalRequest) -> ApprovalReply: + captured.append(request) + return DECLINE # decline; we only want the request + + registry = ToolRegistry() + registry.register(spec) + executor = ToolExecutor( + registry=registry, + workspace=tmp_path, + profile="supervised", # ask before every side-effecting tool + max_result_tokens=2000, + max_total_tokens=10_000, + ask_approval=_ask, + snapshots=SnapshotStore(), + ) + executor.execute(call) + assert captured, "expected an approval request" + return captured[0] + + +def test_approval_display_shows_resolved_path_not_spoof(tmp_path: Path) -> None: + """A spoofing path argument displays as its resolved, workspace-relative + target in the approval head, and matches the file actually acted on.""" + from shellpilot.tools.base import resolve_in_workspace + from shellpilot.tools.patch import WRITE_FILE + + spoof = "notes/../secret.txt" + request = _capture_request( + WRITE_FILE, + tmp_path, + ToolCall(name="write_file", arguments={"path": spoof, "content": "x", "mode": "create"}), + ) + + # The raw, misleading argument must NOT appear in the approval display. + assert spoof not in request.display + # The resolved, workspace-relative target IS shown. + assert "secret.txt" in request.display + assert "notes/" not in request.display + # Display == action: it names the same file resolve_in_workspace targets. + acted_on = resolve_in_workspace(tmp_path, spoof) + assert acted_on.name in request.display + + +def test_approval_display_marks_path_escaping_workspace(tmp_path: Path) -> None: + """A path that resolves outside the workspace renders an honest marker in + the display rather than a fabricated-looking path.""" + from shellpilot.tools.base import OUTSIDE_WORKSPACE_DISPLAY + from shellpilot.tools.patch import WRITE_FILE + + escape = "../outside.txt" + request = _capture_request( + WRITE_FILE, + tmp_path, + ToolCall(name="write_file", arguments={"path": escape, "content": "x", "mode": "create"}), + ) + assert escape not in request.display + assert OUTSIDE_WORKSPACE_DISPLAY in request.display diff --git a/tests/test_input.py b/tests/test_input.py index e9b763b..1a116db 100644 --- a/tests/test_input.py +++ b/tests/test_input.py @@ -29,7 +29,7 @@ def context() -> PromptContext: def test_command_words_derives_clean_phrases() -> None: words = command_words() assert "/help" in words - assert "/exit" in words and "/quit" in words # split combined row + assert "/exit" in words and "/quit" not in words # /quit dropped assert "/model use" in words # argument placeholder stripped assert not any("<" in word for word in words) diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py index e0ebca4..d9126e2 100644 --- a/tests/test_memory_store.py +++ b/tests/test_memory_store.py @@ -89,6 +89,23 @@ def test_render_block_lists_entries_and_caps_tokens(tmp_path: Path) -> None: assert len(tiny) <= 10 * 4 + 40 # truncated to roughly the cap +def test_render_meta_adds_scope_source_for_display_only(tmp_path: Path) -> None: + """meta=True annotates preference lines with (scope, source) for the + /memory show view (folding in what /prefs used to print); the default + (injected) format stays byte-identical and never leaks the tag.""" + global_store = MemoryStore(tmp_path / "global.json") + project_store = MemoryStore(tmp_path / "project.json", project_id="ShellPilot:abc") + global_store.add_preference("Prefer concise answers.", scope="global", source="user") + stores = MemoryStores(global_store=global_store, project_store=project_store) + + injected = stores.render(max_tokens=500) + assert "[pref_001] Prefer concise answers." in injected + assert "(global, user)" not in injected # never reaches the model prompt + + display = stores.render(max_tokens=500, meta=True) + assert "[pref_001] (global, user) Prefer concise answers." in display + + def test_empty_stores_render_nothing(tmp_path: Path) -> None: stores = MemoryStores( global_store=MemoryStore(tmp_path / "g.json"), diff --git a/tests/test_memory_tools.py b/tests/test_memory_tools.py index 8e31cfa..2a4771d 100644 --- a/tests/test_memory_tools.py +++ b/tests/test_memory_tools.py @@ -169,13 +169,17 @@ def test_slash_memory_show_add_forget(tmp_path: Path) -> None: assert harness.stores.global_store.preferences == () -def test_slash_prefs_show_and_edit(tmp_path: Path) -> None: +def test_memory_show_folds_in_scope_source_and_prefs_retired(tmp_path: Path) -> None: + """/prefs was retired into /memory show (v0.10.0): the preference view now + carries the (scope, source) tag /prefs printed, and /prefs is gone.""" harness = MemoryHarness(tmp_path, []) harness.stores.global_store.add_preference("Be concise.", scope="global", source="user") + harness.dispatcher.handle("/memory show") + out = harness.output() + assert "Be concise." in out + assert "(global, user)" in out # folded-in tag, was /prefs show harness.dispatcher.handle("/prefs show") - assert "Be concise." in harness.output() - harness.dispatcher.handle("/prefs edit") - assert "memory.json" in harness.output() + assert "Unknown command: /prefs" in harness.output() def test_memory_compact_merges_with_model_and_keeps_user_entries(tmp_path: Path) -> None: diff --git a/tests/test_model_picker.py b/tests/test_model_picker.py index 8d7514e..4e6cc6d 100644 --- a/tests/test_model_picker.py +++ b/tests/test_model_picker.py @@ -7,7 +7,12 @@ from rich.console import Console -from shellpilot.cli.model_picker import choose_model, resolve_preselect, should_show_picker +from shellpilot.cli.model_picker import ( + choose_model, + confirm_last_model, + resolve_preselect, + should_show_picker, +) from shellpilot.cli.theme import SHELLPILOT_THEME from shellpilot.llm.ollama import LocalModel @@ -144,3 +149,45 @@ def test_picker_marks_untested_models() -> None: assert "untested" in llama_line assert "untested" not in gemma_line assert "untested" not in qwen_line + + +# --------------------------------------------------------------------------- +# confirm_last_model +# --------------------------------------------------------------------------- + + +def test_confirm_enter_flies_last_model() -> None: + # Empty input (Enter) → fly the last model as-is. + console = make_console([""]) + assert confirm_last_model(console, GEMMA) is True + assert GEMMA in console.export_text() + + +def test_confirm_any_key_opens_menu() -> None: + # Any non-empty input → open the full picker. + console = make_console(["m"]) + assert confirm_last_model(console, GEMMA) is False + + +def test_confirm_eof_flies_last_model() -> None: + console = Console( + record=True, width=100, file=io.StringIO(), theme=SHELLPILOT_THEME, force_terminal=True + ) + + def raise_eof(prompt: str = "", **kwargs: object) -> str: + raise EOFError + + console.input = raise_eof # type: ignore[method-assign] + assert confirm_last_model(console, GEMMA) is True + + +def test_confirm_keyboard_interrupt_flies_last_model() -> None: + console = Console( + record=True, width=100, file=io.StringIO(), theme=SHELLPILOT_THEME, force_terminal=True + ) + + def raise_interrupt(prompt: str = "", **kwargs: object) -> str: + raise KeyboardInterrupt + + console.input = raise_interrupt # type: ignore[method-assign] + assert confirm_last_model(console, GEMMA) is True diff --git a/tests/test_ollama_client.py b/tests/test_ollama_client.py index 7ed6ae0..64729b1 100644 --- a/tests/test_ollama_client.py +++ b/tests/test_ollama_client.py @@ -6,9 +6,11 @@ import pytest from shellpilot.llm.ollama import ( + DEFAULT_BASE_URL, LocalModel, OllamaClient, OllamaUnreachableError, + resolve_base_url, ) TAGS_PAYLOAD = { @@ -23,6 +25,12 @@ def make_client(handler: httpx.MockTransport) -> OllamaClient: return OllamaClient(transport=handler) +def test_resolve_base_url_ignores_ambient_env_var(monkeypatch: pytest.MonkeyPatch) -> None: + """Endpoint is config-file-only: an ambient env var must not redirect it (audit F7).""" + monkeypatch.setenv("SHELLPILOT_OLLAMA_BASE_URL", "http://evil.example") + assert resolve_base_url() == DEFAULT_BASE_URL + + def test_health_true_when_tags_endpoint_responds() -> None: transport = httpx.MockTransport(lambda request: httpx.Response(200, json=TAGS_PAYLOAD)) client = make_client(transport) diff --git a/tests/test_plan_flow.py b/tests/test_plan_flow.py index 0768a4f..4816aaf 100644 --- a/tests/test_plan_flow.py +++ b/tests/test_plan_flow.py @@ -268,6 +268,55 @@ def test_declined_command_does_not_run(tmp_path: Path) -> None: assert any("declined" in m.content for m in tool_messages) +def test_steered_command_does_not_run_and_guidance_reaches_model(tmp_path: Path) -> None: + """[e]dit/STEER: the proposed command never runs; the guidance is fed to the + model so it re-proposes; the re-proposal re-enters the normal approval gate. + + Both the wrong command and the (HIGH-risk) re-proposal require approval, so a + second gate invocation PROVES the re-proposal went through the normal flow. + """ + + class _SteerOnceUI(FakeUI): + """Steers the first approval, approves the second (the re-proposal).""" + + def ask_approval(self, request): # type: ignore[no-untyped-def] + self.approval_requests.append(request) + from shellpilot.policy.approvals import APPROVE, ApprovalReply + + if len(self.approval_requests) == 1: + return ApprovalReply( + approved=False, steer_text="the dir is 'build' not 'bulid', use git clean" + ) + return APPROVE # approve the re-proposal + + target = tmp_path / "build" + target.mkdir() + decoy = tmp_path / "bulid" + decoy.mkdir() + + fake = FakeLLM( + script=[ + tool_call("run_command", argv=["rm", "-rf", "bulid"]), # wrong, gets steered + tool_call("run_command", argv=["rm", "-rf", "build"]), # HIGH-risk re-proposal + answer("done"), + ] + ) + ui = _SteerOnceUI() + runtime = make_runtime(fake, ui, tmp_path) + + runtime.run_turn("delete the build dir") + + # The steered (wrong) command never ran — its target survives. + assert decoy.exists() + # The re-proposal re-entered the gate (a SECOND approval request) and ran. + assert len(ui.approval_requests) == 2 + assert not target.exists() + # The guidance reached the model as a tool-role message before the re-proposal. + second_call_msgs = fake.calls[1].messages + tool_msgs = [m for m in second_call_msgs if m.role == "tool"] + assert any("the dir is 'build' not 'bulid', use git clean" in m.content for m in tool_msgs) + + def test_low_risk_command_runs_without_approval_in_balanced(tmp_path: Path) -> None: fake = FakeLLM(script=[tool_call("run_command", argv=["pwd"]), answer("done")]) ui = FakeUI() diff --git a/tests/test_render.py b/tests/test_render.py index a1a975e..e6016c3 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -11,7 +11,6 @@ from shellpilot.cli.render import ( approval_block, badge, - banner, context_line, output_truncation, plan_panel, @@ -19,7 +18,6 @@ render_diff, tool_call, tool_result, - turn_stats, word_highlight_ranges, ) from shellpilot.cli.theme import SHELLPILOT_THEME, UNICODE_GLYPHS @@ -47,12 +45,6 @@ def make_diff(before: str, after: str, name: str = "hello.py") -> str: ) -def test_banner_shows_version_and_model() -> None: - out = rendered(banner("9.9.9", "gemma4:e4b", "balanced")) - assert "ShellPilot 9.9.9" in out - assert "gemma4:e4b · balanced" in out - - def test_context_line_abbreviates_home() -> None: home = Path("/Users/someone") out = rendered(context_line(home / "proj", "gemma4:e4b", "balanced", home=home)) @@ -83,6 +75,17 @@ def test_tool_result_marks_success_and_failure() -> None: assert GLYPHS.cross in bad.plain +def test_tool_renderers_sanitize_external_text() -> None: + call = tool_call("patch_file\x1b[2J\x00", "path=\tfile.py\x07", GLYPHS) + result = tool_result(True, "updated\x0b file.py\x7f", GLYPHS) + + for output in (call.plain, result.plain): + assert not any(char in output for char in "\x1b\x00\x07\x0b\x7f\t") + assert "patch_file" in call.plain + assert "path=" in call.plain and "file.py" in call.plain + assert "updated file.py" in result.plain + + def test_word_highlight_ranges_similar_pair() -> None: result = word_highlight_ranges('print("done")', 'print("Goodbye World")') assert result is not None @@ -116,6 +119,34 @@ def test_render_diff_pure_add_and_remove() -> None: assert "- b" in rendered(render_diff(remove_only, GLYPHS)) +def test_render_diff_changed_lines_are_separate_full_width_bars() -> None: + """A changed line renders as its own red removal row then its own green + addition row (DESIGN section 31.4 full-line backgrounds) — never paired onto + one visual line. Each changed row's colored background must span the full + content width so removal (red) and addition (green) read as distinct bars. + """ + from shellpilot.cli.render import _diff_rows + + # Removal shorter than its addition: without full-width fill the red bar + # would stop mid-line and the two rows would not read as separate diff lines. + diff = make_diff('print("done")\n', 'print("Goodbye World")\n') + rows, _ = _diff_rows(diff, GLYPHS) + remove_row = next(r for r in rows if "- " in r.plain) + add_row = next(r for r in rows if "+ " in r.plain) + + def base_bg_end(row: object, style: str) -> int: + return next(span.end for span in row.spans if span.style == style) # type: ignore[attr-defined] + + remove_end = base_bg_end(remove_row, "sp.diff.remove") + add_end = base_bg_end(add_row, "sp.diff.add") + # Both colored backgrounds reach the same full content width: the removal's + # red bar is padded to match the longer addition's green bar. + assert remove_end == add_end + # And the fill genuinely extends past the removal's own short text (the bug: + # the red bar stopped at len("- print(\"done\")") instead of filling). + assert remove_end > len('- print("done")') + 2 # gutter ("1 ") + text + + def test_render_diff_sanitizes_tabs_crlf_and_truncation_marker() -> None: diff = make_diff("x\r\n", "x\r\n\tindented\r\n") + "... (42 more lines)\n" out = rendered(render_diff(diff, GLYPHS)) @@ -125,6 +156,61 @@ def test_render_diff_sanitizes_tabs_crlf_and_truncation_marker() -> None: assert "42 more lines" in out +def _additions_diff(count: int, name: str = "big.py") -> str: + """A unified diff that adds *count* numbered lines to an empty file.""" + after = "".join(f"line {i}\n" for i in range(count)) + return make_diff("", after, name=name) + + +def test_render_diff_max_rows_param_exists() -> None: + """CI guard: render_diff keeps its keyword-only max_rows parameter.""" + import inspect + + sig = inspect.signature(render_diff) + assert "max_rows" in sig.parameters + + +def test_render_diff_max_rows_caps_output_with_footer() -> None: + diff = _additions_diff(30) + out = rendered(render_diff(diff, GLYPHS, max_rows=10)) + assert "line 0" in out + assert "line 9" in out # the 10th content row is shown + assert "line 25" not in out # rows past the cap are hidden + assert f"{GLYPHS.ellipsis} (+" in out + assert "more)" in out + + +def test_render_diff_max_rows_none_shows_full() -> None: + diff = _additions_diff(30) + out = rendered(render_diff(diff, GLYPHS, max_rows=None)) + assert "line 0" in out + assert "line 29" in out + assert "more)" not in out + + +def test_render_diff_max_rows_at_exact_boundary_no_footer() -> None: + """rows == max_rows is inclusive: no cap, no footer.""" + diff = _additions_diff(8) + # An 8-addition diff renders exactly 8 content rows. + out = rendered(render_diff(diff, GLYPHS, max_rows=8)) + assert "line 7" in out + assert "more)" not in out + + +def test_render_diff_footer_style_is_faint() -> None: + from rich.console import Group + from rich.text import Text + + diff = _additions_diff(30) + panel = render_diff(diff, GLYPHS, max_rows=5) + body = panel.renderable + assert isinstance(body, Group) + footer = body.renderables[-1] + assert isinstance(footer, Text) + assert footer.style == "sp.faint" + assert footer.plain.startswith(f"{GLYPHS.ellipsis} (+") + + def test_badge_chips_and_plain_degradation() -> None: assert badge("medium").plain == " MEDIUM " assert badge("high").plain == " HIGH " @@ -172,15 +258,64 @@ def test_plan_panel_gate_and_step_lines() -> None: assert f"{GLYPHS.current} 2" in line.plain and "Second" in line.plain -def test_turn_stats_formats_and_warns() -> None: - calm = turn_stats(2.13, 1_400, 18, warn=False) - assert "2.1s · 1.4k tokens · ctx 18%" in calm.plain - assert not any(span.style == "sp.warn" for span in calm.spans) +def test_output_truncation_marker() -> None: + assert "+214 lines" in output_truncation(214, GLYPHS).plain - hot = turn_stats(0.5, 812, 84, warn=True) - assert "812 tokens" in hot.plain - assert any(span.style == "sp.warn" for span in hot.spans) +def test_plan_panel_sanitizes_goal() -> None: + """plan.goal is model-controlled; raw control chars must not reach the terminal.""" + plan = TaskPlan( + task_id="20260611-031500-sec-test", + goal="Safe goal\x1b[2Jforged\x00", + user_intent="test", + workspace=Path("/tmp/ws"), + profile="balanced", + steps=[PlanStep(title="Only step")], + ) + out = rendered(plan_panel(plan, GLYPHS)) + assert "\x1b" not in out + assert "\x00" not in out + assert "Safe goal" in out + assert "forged" in out # visible text preserved, only escape stripped + + +def test_plan_step_line_sanitizes_title() -> None: + """step.title is model-controlled; raw escape sequences must not reach the terminal.""" + step = PlanStep(title="Compile\x1b[2Jspoof\x00", status="active") + line = plan_step_line(1, step, GLYPHS) + assert "\x1b" not in line.plain + assert "\x00" not in line.plain + assert "Compile" in line.plain + assert "spoof" in line.plain # visible text preserved + + +def test_plan_step_line_sanitizes_all_statuses() -> None: + """Sanitization must fire for every branch in plan_step_line (completed/active/skipped/todo).""" + poison = "\x1b[2Jx\x00" + for status in ("completed", "active", "skipped", "pending"): + step = PlanStep(title=f"Step{poison}", status=status if status != "pending" else "pending") + line = plan_step_line(1, step, GLYPHS) + assert "\x1b" not in line.plain, f"escape leaked for status={status!r}" + assert "\x00" not in line.plain, f"null leaked for status={status!r}" + assert "Step" in line.plain, f"visible text lost for status={status!r}" + + +def test_plan_panel_normal_goal_unchanged() -> None: + """Sanitization must not alter clean goal text.""" + plan = TaskPlan( + task_id="20260611-031500-normal", + goal="Do the demo", + user_intent="demo", + workspace=Path("/tmp/ws"), + profile="balanced", + steps=[PlanStep(title="Only step")], + ) + out = rendered(plan_panel(plan, GLYPHS)) + assert "Goal: Do the demo" in out -def test_output_truncation_marker() -> None: - assert "+214 lines" in output_truncation(214, GLYPHS).plain + +def test_plan_step_line_normal_title_unchanged() -> None: + """Sanitization must not alter clean step title text.""" + step = PlanStep(title="Run the tests", status="active") + line = plan_step_line(1, step, GLYPHS) + assert "Run the tests" in line.plain diff --git a/tests/test_run_command.py b/tests/test_run_command.py index caff080..2232513 100644 --- a/tests/test_run_command.py +++ b/tests/test_run_command.py @@ -8,6 +8,7 @@ from unittest.mock import patch from shellpilot.llm.messages import ToolCall +from shellpilot.policy.approvals import APPROVE, DECLINE, ApprovalReply from shellpilot.runtime.executor import ToolExecutor from shellpilot.tools.base import ToolContext from shellpilot.tools.command import ( @@ -43,9 +44,9 @@ def _approval_spy(approved: bool = True) -> tuple[Any, list[Any]]: """Return (asker_fn, calls_list); calls_list records invocations.""" calls: list[Any] = [] - def _ask(request: Any) -> bool: + def _ask(request: Any) -> ApprovalReply: calls.append(request) - return approved + return APPROVE if approved else DECLINE return _ask, calls @@ -491,7 +492,7 @@ def _make_executor_with_timeout(tmp_path: Path, ceiling: int) -> ToolExecutor: max_result_tokens=2000, max_total_tokens=10_000, command_timeout_seconds=ceiling, - ask_approval=lambda req: True, + ask_approval=lambda req: APPROVE, ) diff --git a/tests/test_sessions.py b/tests/test_sessions.py index f576b24..79bc61f 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -5,6 +5,8 @@ import base64 import hashlib import json +import os +import stat from pathlib import Path from shellpilot.config.loader import load_config @@ -428,3 +430,81 @@ def test_export_already_redacted_transcript_is_idempotent(tmp_path: Path) -> Non assert "x.py" in text # no spurious [REDACTED] markers introduced assert "[REDACTED]" not in text + + +# --------------------------------------------------------------------------- +# F13: at-rest file permissions — session file must be 0600, parent dir 0700 +# --------------------------------------------------------------------------- + + +def test_session_file_created_mode_0600(tmp_path: Path) -> None: + """A freshly created session transcript must have mode 0600.""" + store = make_store(tmp_path) + store.record_message(Message(role="user", content="hello")) + file_mode = stat.S_IMODE(os.stat(store.path).st_mode) + assert file_mode == 0o600, f"expected 0o600, got {oct(file_mode)}" + + +def test_session_parent_dir_created_mode_0700(tmp_path: Path) -> None: + """The sessions directory created by SessionStore._append must have mode 0700.""" + store = make_store(tmp_path) + store.record_message(Message(role="user", content="hello")) + dir_mode = stat.S_IMODE(os.stat(store.path.parent).st_mode) + assert dir_mode == 0o700, f"expected 0o700, got {oct(dir_mode)}" + + +def test_session_append_preserves_content_at_0600(tmp_path: Path) -> None: + """Multiple appends must accumulate without truncating; file stays 0600.""" + store = make_store(tmp_path) + store.record_message(Message(role="user", content="first")) + store.record_message(Message(role="assistant", content="second")) + loaded = SessionStore.load(store.path) + assert [m.content for m in loaded.messages] == ["first", "second"] + file_mode = stat.S_IMODE(os.stat(store.path).st_mode) + assert file_mode == 0o600, f"expected 0o600, got {oct(file_mode)}" + + +# --------------------------------------------------------------------------- +# recent() — banner data: newest-first (label, mtime), label from 1st user msg +# --------------------------------------------------------------------------- + + +def test_recent_empty_dir_returns_empty(tmp_path: Path) -> None: + assert SessionStore.recent(tmp_path / "sessions") == [] + + +def test_recent_label_is_first_user_message(tmp_path: Path) -> None: + store = make_store(tmp_path) + store.write_meta(model="gemma4:e4b", profile="balanced", workspace=tmp_path) + store.record_message(Message(role="user", content="fix the off-by-one in parser")) + store.record_message(Message(role="assistant", content="done")) + recent = SessionStore.recent(store.path.parent) + assert len(recent) == 1 + label, mtime = recent[0] + assert label == "fix the off-by-one in parser" + assert isinstance(mtime, float) + + +def test_recent_label_truncated(tmp_path: Path) -> None: + store = make_store(tmp_path) + store.record_message(Message(role="user", content="x" * 50)) + label, _ = SessionStore.recent(store.path.parent)[0] + assert label.endswith("…") + assert len(label) == 33 # 32 chars + ellipsis + + +def test_recent_falls_back_to_model_when_no_user_message(tmp_path: Path) -> None: + store = make_store(tmp_path) + store.write_meta(model="gemma4:e4b", profile="balanced", workspace=tmp_path) + label, _ = SessionStore.recent(store.path.parent)[0] + assert label == "gemma4:e4b" + + +def test_recent_newest_first_and_capped(tmp_path: Path) -> None: + sessions = tmp_path / "sessions" + for i in range(5): + s = SessionStore(sessions, f"sess-{i}") + s.record_message(Message(role="user", content=f"msg {i}")) + os.utime(s.path, (1000.0 + i, 1000.0 + i)) + recent = SessionStore.recent(sessions, limit=3) + assert [label for label, _ in recent] == ["msg 4", "msg 3", "msg 2"] diff --git a/tests/test_skill_injection.py b/tests/test_skill_injection.py index f279ab9..ff79ffc 100644 --- a/tests/test_skill_injection.py +++ b/tests/test_skill_injection.py @@ -170,6 +170,44 @@ def test_web_grounding_skill_injected_when_web_enabled(tmp_path: Path) -> None: assert "fetch only URLs from the search results" in system_text +def test_workflow_skill_injected_and_listed_when_enabled(tmp_path: Path) -> None: + """Enabling a workflow builtin injects its body and lists its on-demand refs.""" + settings = Settings(skills=SkillSettings(enabled=("debugging",))) + fake = FakeLLM(script=[answer("ok")]) + ui = FakeUI() + runtime = _make_runtime(fake, ui, tmp_path, settings=settings, skills=_builtin_skills()) + + runtime.run_turn("there's a bug") + + system_text = fake.calls[0].messages[0].content + # Body injected. + assert "## Skill: debugging" in system_text + assert "Debug by method" in system_text + assert "debugging" in system_text + # On-demand references advertised in the readable menu, not injected wholesale. + assert "Readable docs (open with skill_read)" in system_text + assert "method" in system_text + assert "common-traps" in system_text + # The deep reference text itself is NOT force-injected. + assert "The Debugging Loop" not in system_text + + +def test_default_session_unaffected_by_workflow_skills(tmp_path: Path) -> None: + """A default session (no skills enabled) injects no workflow skill and no menu.""" + fake = FakeLLM(script=[answer("ok")]) + ui = FakeUI() + runtime = _make_runtime(fake, ui, tmp_path, skills=_builtin_skills()) + + runtime.run_turn("hello") + + system_text = fake.calls[0].messages[0].content + for name in ("debugging", "verification", "code-review", "git-workflow"): + assert f"## Skill: {name}" not in system_text + assert "Readable docs" not in system_text + # Only the always-on builtin is loaded by default. + assert "Loaded skills: context-management." in system_text + + # --------------------------------------------------------------------------- # Readable menu (skills readable block) — Task 2 of v0.9.0 # --------------------------------------------------------------------------- diff --git a/tests/test_skills.py b/tests/test_skills.py index 87bcf53..e4e76ef 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -341,9 +341,13 @@ def test_discover_builtin_root_resolves() -> None: ) builtin_names = [s.name for s in skills if s.root == "builtin"] assert builtin_names == [ + "code-review", "context-management", + "debugging", + "git-workflow", "planning", "skill-authoring", + "verification", "web-grounding", ] @@ -432,14 +436,100 @@ def test_builtin_trigger_map_and_resources_by_folder_name() -> None: assert builtins["skill-authoring"].scripts == () +# --------------------------------------------------------------------------- +# Workflow skills (v0.9.x — opt-in progressive-disclosure showcase) +# --------------------------------------------------------------------------- + +WORKFLOW_SKILLS: dict[str, set[str]] = { + "debugging": {"method", "common-traps"}, + "verification": {"checklist"}, + "code-review": {"dimensions"}, + "git-workflow": {"commits", "recovery"}, +} + + +@pytest.mark.parametrize("name", sorted(WORKFLOW_SKILLS)) +def test_workflow_skill_valid_enabled_with_on_demand_references(name: str) -> None: + """Each workflow skill is valid, ENABLED-gated, with on-demand references only.""" + skill = _builtin_skills()[name] + assert skill.valid is True + assert skill.error == "" + assert skill.triggers == (SkillTrigger.ENABLED,) + assert skill.body.strip(), "workflow skill body must not be empty" + # References are on-demand (no trigger) and match the expected set. + assert {r.name for r in skill.references} == WORKFLOW_SKILLS[name] + assert all(r.trigger is None for r in skill.references) + assert all(r.kind == "reference" for r in skill.references) + # These skills ship no templates or scripts. + assert skill.templates == () + assert skill.scripts == () + + +@pytest.mark.parametrize("name", sorted(WORKFLOW_SKILLS)) +def test_workflow_skill_body_within_token_cap(name: str) -> None: + """The injected body is lean — well under the per-skill cap and the ~180-token aim.""" + max_tokens = 800 + skill = _builtin_skills(max_tokens=max_tokens)[name] + assert skill.est_tokens <= max_tokens + # Lean body: depth lives in on-demand references, not the injected text. + assert skill.est_tokens <= 200 + + +@pytest.mark.parametrize("name", sorted(WORKFLOW_SKILLS)) +def test_workflow_skill_body_routes_to_its_references(name: str) -> None: + """The body names skill_read and each of its on-demand references by name.""" + skill = _builtin_skills()[name] + assert "skill_read" in skill.body + for resource_name in WORKFLOW_SKILLS[name]: + assert resource_name in skill.body, f"body of {name} should name reference {resource_name}" + + +@pytest.mark.parametrize("name", sorted(WORKFLOW_SKILLS)) +def test_workflow_skill_references_readable_by_name(name: str) -> None: + """Each on-demand reference is readable through skill_read by its exact name.""" + from shellpilot.tools.base import ToolContext + from shellpilot.tools.skill_tools import make_skill_read_tool + + skills = tuple(s for s in _builtin_skills().values()) + spec = make_skill_read_tool(skills) + ctx = ToolContext(workspace=Path("/nonexistent"), max_result_tokens=4000) + for resource_name in WORKFLOW_SKILLS[name]: + result = spec.handler(ctx, {"skill": name, "resource": resource_name}) + assert result.success is True, f"{name}/{resource_name} should be readable" + assert result.content.strip() + + +@pytest.mark.parametrize("name", sorted(WORKFLOW_SKILLS)) +def test_workflow_skill_body_names_no_bogus_tool(name: str) -> None: + """Guard the fabricated-tool-name class (v0.9.0): the search tool is + `search_text`, never a bare `search`. A SKILL body injects as authoritative + instruction, so it must name real registered tools.""" + body = _builtin_skills()[name].body + assert "`search`" not in body, f"{name} body references a non-existent `search` tool" + + def test_builtin_layout_loads_with_importlib_resources() -> None: root = importlib.resources.files("shellpilot.skills.builtin") expected_files = { + "code-review": { + "SKILL.md", + "references/dimensions.md", + }, "context-management": { "SKILL.md", "references/context-budgeting.md", "references/file-triage.md", }, + "debugging": { + "SKILL.md", + "references/method.md", + "references/common-traps.md", + }, + "git-workflow": { + "SKILL.md", + "references/commits.md", + "references/recovery.md", + }, "planning": { "SKILL.md", "references/active.md", @@ -457,6 +547,10 @@ def test_builtin_layout_loads_with_importlib_resources() -> None: "templates/SKILL.md", "templates/skill-eval.md", }, + "verification": { + "SKILL.md", + "references/checklist.md", + }, "web-grounding": {"SKILL.md"}, } diff --git a/tests/test_slash.py b/tests/test_slash.py index 07fa71a..c1d9fea 100644 --- a/tests/test_slash.py +++ b/tests/test_slash.py @@ -53,10 +53,11 @@ def output(self) -> str: return self.console.export_text() -def test_exit_and_quit(tmp_path: Path) -> None: +def test_exit_only_quit_dropped(tmp_path: Path) -> None: harness = Harness(tmp_path) assert harness.dispatcher.handle("/exit") is SlashAction.EXIT - assert harness.dispatcher.handle("/quit") is SlashAction.EXIT + # /quit was dropped (one exit command); it is now an unknown command. + assert harness.dispatcher.handle("/quit") is SlashAction.CONTINUE def test_help_lists_commands(tmp_path: Path) -> None: @@ -92,6 +93,18 @@ def test_status_shows_model_and_context(tmp_path: Path) -> None: assert "gemma4:e4b" in out assert "balanced" in out assert "8192" in out + # Default loopback session with a local model reports a local locality. + assert "Locality: local" in out + + +def test_status_locality_remote_for_cloud_model(tmp_path: Path) -> None: + """A live cloud model flips the /status locality line to REMOTE.""" + harness = Harness(tmp_path) + harness.runtime.set_model("nemotron-3-nano:30b-cloud") + harness.dispatcher.handle("/status") + out = harness.output() + assert "Locality: REMOTE" in out + assert "cloud" in out def test_model_list_shows_all_models_with_tags(tmp_path: Path) -> None: @@ -187,6 +200,60 @@ def test_config_reload_calls_loader(tmp_path: Path) -> None: assert "reloaded" in harness.output().lower() +def test_config_edit_creates_starter_when_absent(tmp_path: Path) -> None: + harness = Harness(tmp_path) + config_file = tmp_path / "config.toml" + assert not config_file.exists() + + harness.dispatcher.handle("/config edit") + + # The starter template now exists at the printed path... + assert config_file.is_file() + out = harness.output() + # Rich may wrap the long tmp path across lines; rejoin before matching. + flat = "".join(out.split()) + assert str(config_file) in flat + assert "starter" in out.lower() + # ...is valid TOML that load_config accepts and resolves to defaults + # (every key commented out => no override of the built-in defaults). + loaded = load_config( + user_config_file=config_file, + project_config_file=tmp_path / "missing-project.toml", + env={}, + ) + assert ( + loaded.settings + == load_config( + user_config_file=tmp_path / "missing-user.toml", + project_config_file=tmp_path / "missing-project.toml", + env={}, + ).settings + ) + + +def test_config_edit_starter_has_owner_only_perms(tmp_path: Path) -> None: + harness = Harness(tmp_path) + config_file = tmp_path / "config.toml" + harness.dispatcher.handle("/config edit") + assert config_file.stat().st_mode & 0o777 == 0o600 + + +def test_config_edit_does_not_overwrite_existing(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + original = '[model]\ndefault = "gemma4:e4b"\n' + config_file.write_text(original, encoding="utf-8") + before_mtime = config_file.stat().st_mtime_ns + + harness = Harness(tmp_path) + harness.dispatcher.handle("/config edit") + + assert config_file.read_text(encoding="utf-8") == original + assert config_file.stat().st_mtime_ns == before_mtime + out = harness.output() + assert str(config_file) in "".join(out.split()) + assert "starter" not in out.lower() + + def test_compact_status_shows_thresholds(tmp_path: Path) -> None: harness = Harness(tmp_path) harness.dispatcher.handle("/compact status") @@ -639,6 +706,101 @@ def test_config_set_model_options_rejected(tmp_path: Path) -> None: assert not (tmp_path / "overrides.json").exists() +def _overrides_data(tmp_path: Path) -> dict: + import json + + path = tmp_path / "overrides.json" + if not path.exists(): + return {} + return json.loads(path.read_text()) + + +def test_config_set_security_profile_confirmed_persists(tmp_path: Path) -> None: + """/config set runtime.security_profile is high-stakes AND live: an amber + warning + confirm gates it; a yes persists the override and lowers the + profile THIS session (the profile is read per turn, unlike the boot-only + egress keys). The warning must not falsely defer a safety downgrade to next + session, and points the user at /profile use for an unsaved session-only + change.""" + harness = Harness(tmp_path, confirm_answer=True) + harness.dispatcher.handle("/config set runtime.security_profile supervised") + out = harness.output() + assert "lowers the local safety profile" in out + assert "/profile use" in out + # Live: the downgrade applies now, so the warning must NOT claim next session. + assert "next session" not in out + assert _overrides_data(tmp_path).get("runtime.security_profile") == "supervised" + # Applied to the running session, not merely persisted for next boot. + assert harness.runtime.settings.runtime.security_profile == "supervised" + + +def test_config_set_security_profile_declined_not_persisted(tmp_path: Path) -> None: + """Declining the confirm leaves overrides.json untouched and the profile as-is.""" + harness = Harness(tmp_path, confirm_answer=False) + harness.dispatcher.handle("/config set runtime.security_profile supervised") + out = harness.output() + assert "unchanged" in out.lower() + assert not (tmp_path / "overrides.json").exists() + assert harness.runtime.settings.runtime.security_profile == "balanced" + + +def test_config_set_tools_web_confirmed_persists(tmp_path: Path) -> None: + """/config set tools.web is high-stakes: confirmed → persisted with an + egress warning + boot-only note.""" + harness = Harness(tmp_path, confirm_answer=True) + harness.dispatcher.handle("/config set tools.web true") + out = harness.output() + assert "enables web egress" in out + assert "next session" in out + assert _overrides_data(tmp_path).get("tools.web") is True + + +def test_config_set_tools_web_declined_not_persisted(tmp_path: Path) -> None: + """Declining the tools.web confirm persists nothing and leaves web off.""" + harness = Harness(tmp_path, confirm_answer=False) + harness.dispatcher.handle("/config set tools.web true") + assert "unchanged" in harness.output().lower() + assert not (tmp_path / "overrides.json").exists() + assert harness.runtime.settings.tools.web is False + + +def test_config_set_base_url_confirmed_persists(tmp_path: Path) -> None: + """/config set model.base_url is high-stakes: confirmed → persisted with an + endpoint-change warning.""" + harness = Harness(tmp_path, confirm_answer=True) + harness.dispatcher.handle("/config set model.base_url http://127.0.0.1:9999") + out = harness.output() + assert "changes the model endpoint" in out + assert _overrides_data(tmp_path).get("model.base_url") == "http://127.0.0.1:9999" + + +def test_config_set_base_url_declined_not_persisted(tmp_path: Path) -> None: + """Declining the model.base_url confirm persists nothing.""" + harness = Harness(tmp_path, confirm_answer=False) + harness.dispatcher.handle("/config set model.base_url http://127.0.0.1:9999") + assert "unchanged" in harness.output().lower() + assert not (tmp_path / "overrides.json").exists() + + +def test_config_set_allow_cloud_confirmed_persists(tmp_path: Path) -> None: + """/config set model.allow_cloud is high-stakes: confirmed → persisted with + a cloud-egress warning (the consent gate still fires at boot regardless).""" + harness = Harness(tmp_path, confirm_answer=True) + harness.dispatcher.handle("/config set model.allow_cloud true") + out = harness.output() + assert "enables cloud egress" in out + assert _overrides_data(tmp_path).get("model.allow_cloud") is True + + +def test_config_set_allow_cloud_declined_not_persisted(tmp_path: Path) -> None: + """Declining the model.allow_cloud confirm persists nothing.""" + harness = Harness(tmp_path, confirm_answer=False) + harness.dispatcher.handle("/config set model.allow_cloud true") + assert "unchanged" in harness.output().lower() + assert not (tmp_path / "overrides.json").exists() + assert harness.runtime.settings.model.allow_cloud is False + + def test_config_unset_existing_reverts(tmp_path: Path) -> None: """/config unset removes the override and shows the reverted source.""" import json @@ -668,13 +830,18 @@ def test_config_unset_absent_key_reports_noop(tmp_path: Path) -> None: assert "no override" in out.lower() -def test_config_reset_alias_for_unset(tmp_path: Path) -> None: - """/config reset is an alias for /config unset .""" +def test_config_reset_key_no_longer_aliases_unset(tmp_path: Path) -> None: + """/config reset was demoted (v0.10.0): only /config unset + removes one key; /config reset falls to the usage message and changes + nothing. /config reset (no key) still clears all.""" harness = Harness(tmp_path) harness.dispatcher.handle("/config set runtime.max_tool_turns 20") assert harness.runtime.settings.runtime.max_tool_turns == 20 harness.dispatcher.handle("/config reset runtime.max_tool_turns") - assert harness.runtime.settings.runtime.max_tool_turns == 40 + assert harness.runtime.settings.runtime.max_tool_turns == 20 # alias gone — unchanged + assert "Usage:" in harness.output() + harness.dispatcher.handle("/config unset runtime.max_tool_turns") + assert harness.runtime.settings.runtime.max_tool_turns == 40 # unset still works def test_config_reset_all_declined(tmp_path: Path) -> None: diff --git a/tests/test_status_bar.py b/tests/test_status_bar.py new file mode 100644 index 0000000..9a6b781 --- /dev/null +++ b/tests/test_status_bar.py @@ -0,0 +1,138 @@ +"""Tests for the persistent bottom status bar builder (design section 31/32).""" + +from __future__ import annotations + +from pathlib import Path + +from shellpilot.cli.status_bar import ( + COLOR_ACCENT, + COLOR_ERROR, + COLOR_WARN, + ctx_percent, + status_bar, +) + + +def _plain(fragments: list[tuple[str, str]]) -> str: + """Concatenate the visible text of a prompt_toolkit FormattedText list.""" + return "".join(text for _style, text in fragments) + + +def _styles_for(fragments: list[tuple[str, str]], needle: str) -> str: + """The style string of the first fragment whose text contains *needle*.""" + for style, text in fragments: + if needle in text: + return style + raise AssertionError(f"no fragment containing {needle!r}") + + +def test_ctx_percent_thresholds() -> None: + # Pure %: rounded, clamped to [0, 100]; zero total never divides. + assert ctx_percent(0, 1000) == 0 + assert ctx_percent(120, 1000) == 12 + assert ctx_percent(1000, 1000) == 100 + assert ctx_percent(2000, 1000) == 100 # clamps over budget + assert ctx_percent(50, 0) == 0 # no budget → no division + + +def test_local_session_is_green_and_local() -> None: + bar = status_bar( + workspace=Path.home() / "Projects", + model="gemma4:e4b", + profile="balanced", + is_cloud=False, + ctx_pct=12, + ) + text = _plain(bar) + # Home-abbreviated workspace, model, profile, locality, ctx all present. + assert "~/Projects" in text + assert "gemma4:e4b" in text + assert "balanced" in text + assert "local" in text + assert "12%" in text + assert "ctx" in text + # Local: NO amber anywhere — the cloud emphasis must be unmistakably absent. + assert COLOR_WARN not in "".join(style for style, _ in bar) + # Model name is GREEN when local. + assert COLOR_ACCENT in _styles_for(bar, "gemma4:e4b") + # The local locality glyph (filled dot) is green. + assert "●" in text + assert COLOR_ACCENT in _styles_for(bar, "●") + + +def test_cloud_session_is_amber_and_carries_emphasis() -> None: + bar = status_bar( + workspace=Path.home() / "Projects", + model="gemma4:31b-cloud", + profile="balanced", + is_cloud=True, + ctx_pct=41, + ) + text = _plain(bar) + assert "gemma4:31b-cloud" in text + # Unmistakable cloud indicator: the cloud glyph + CLOUD label. + assert "☁" in text + assert "CLOUD" in text + # Model name is AMBER when egressing. + assert COLOR_WARN in _styles_for(bar, "gemma4:31b-cloud") + # Locality segment is amber + bold. + cloud_style = _styles_for(bar, "CLOUD") + assert COLOR_WARN in cloud_style + assert "bold" in cloud_style + # The bar carries an amber emphasis even on a non-locality fragment (the + # "wash" adapted to a terminal: amber-tinted separators), distinguishing it + # from a local bar at a glance. + assert any(COLOR_WARN in style and "●" not in textfrag for style, textfrag in bar) + + +def test_ctx_color_thresholds_lo_mid_hi() -> None: + lo = status_bar(workspace=Path.home(), model="m", profile="p", is_cloud=False, ctx_pct=20) + mid = status_bar(workspace=Path.home(), model="m", profile="p", is_cloud=False, ctx_pct=65) + hi = status_bar(workspace=Path.home(), model="m", profile="p", is_cloud=False, ctx_pct=92) + assert COLOR_ACCENT in _styles_for(lo, "20%") + assert COLOR_WARN in _styles_for(mid, "65%") + assert COLOR_ERROR in _styles_for(hi, "92%") + + +def test_separators_present_between_left_segments() -> None: + bar = status_bar( + workspace=Path.home() / "Projects", + model="gemma4:e4b", + profile="balanced", + is_cloud=False, + ctx_pct=12, + ) + text = _plain(bar) + # Faint dot separators join the left segments (dir · model · profile · loc). + assert text.count("·") >= 3 + + +def test_workspace_home_is_abbreviated() -> None: + bar = status_bar( + workspace=Path.home(), + model="m", + profile="p", + is_cloud=False, + ctx_pct=0, + ) + text = _plain(bar) + assert "~" in text + # The literal home path must not leak verbatim. + assert str(Path.home()) not in text + + +def test_control_chars_in_workspace_are_sanitized() -> None: + # A workspace path is user-controlled and could carry odd bytes; the bar must + # strip control/ANSI before render so it cannot repaint the terminal. + nasty = Path("/tmp/\x1b[31mhack\x07\x00bad") + bar = status_bar( + workspace=nasty, + model="m", + profile="p", + is_cloud=False, + ctx_pct=0, + ) + text = _plain(bar) + assert "\x1b" not in text + assert "\x07" not in text + assert "\x00" not in text diff --git a/tests/test_streaming.py b/tests/test_streaming.py index e89bb8c..09addc0 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -2,17 +2,22 @@ from __future__ import annotations +import difflib import io import random from pathlib import Path +import pytest from rich.cells import cell_len from rich.console import Console +from rich.markdown import Markdown from rich.text import Text +import shellpilot.cli.streaming as streaming_mod from shellpilot.cli.streaming import ( FLIGHT_PHASES, AviationSpinner, + DiffReveal, ResponseStream, phase_for_elapsed, ) @@ -63,6 +68,72 @@ def test_response_stream_renders_markdown_on_terminals() -> None: assert "**bold**" not in out # markdown was rendered, not echoed +def test_response_stream_sanitizes_every_markdown_construction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sources: list[str] = [] + real_markdown = streaming_mod.Markdown + + def recording_markdown(markup: str) -> Markdown: + sources.append(markup) + return real_markdown(markup) + + monkeypatch.setattr(streaming_mod, "Markdown", recording_markdown) + console = terminal_console() + stream = ResponseStream(console) + stream.feed("alpha\x1b[2J\x00\n\n**bold**\x07") + stream.finish() + + assert len(sources) >= 2 + assert all(not any(char in source for char in "\x1b\x00\x07") for source in sources) + assert "alpha" in sources[-1] + assert "\n\n**bold**" in sources[-1] + + +def test_response_stream_sanitizes_plain_passthrough_and_buffer() -> None: + console = plain_console() + stream = ResponseStream(console) + stream.feed("plain\x1b[2J\x00\ttext\x7f\n") + + out = console.export_text() + assert not any(char in out for char in "\x1b\x00\x7f\t") + assert not any(char in stream._buffer for char in "\x1b\x00\x7f\t") + assert "plain" in out and "text" in out + assert out.endswith("\n") + + +def test_response_stream_preserves_multiline_fenced_markdown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """B1 sanitization must not corrupt legitimate multi-line fenced markdown. + + Asserts on the markup handed to Markdown — not the Live-streamed export_text, + which records intermediate frames and so cannot equal a single static print + (every other terminal test here uses substring checks for the same reason). + """ + sources: list[str] = [] + real_markdown = streaming_mod.Markdown + + def recording_markdown(markup: str) -> Markdown: + sources.append(markup) + return real_markdown(markup) + + monkeypatch.setattr(streaming_mod, "Markdown", recording_markdown) + content = 'Intro paragraph.\n\n```python\nprint("hello")\n```\n\nFinal **bold** line.\n' + console = terminal_console() + stream = ResponseStream(console) + for token in (content[:13], content[13:31], content[31:]): + stream.feed(token) + stream.finish() + + # The final flush hands the complete, un-corrupted markdown to Markdown: + # the fenced code block and surrounding text survive sanitization intact. + assert sources[-1] == content + out = console.export_text() + assert 'print("hello")' in out # code-block body rendered + assert "Final" in out and "bold" in out # trailing text + bold rendered + + def test_response_stream_final_render_is_complete() -> None: console = terminal_console() stream = ResponseStream(console) @@ -351,6 +422,113 @@ def stop(self) -> None: assert refresh is False, "expected refresh=False on the clearing update" +# --------------------------------------------------------------------------- +# DiffReveal: approval-time scrolling reveal (design section 31.4) +# --------------------------------------------------------------------------- + + +def _additions_diff(count: int, name: str = "big.py") -> str: + """A unified diff that adds *count* numbered lines to an empty file.""" + before = "" + after = "".join(f"line {i}\n" for i in range(count)) + return "".join( + difflib.unified_diff( + before.splitlines(keepends=True), + after.splitlines(keepends=True), + fromfile=f"a/{name}", + tofile=f"b/{name}", + ) + ) + + +class _SpyLive: + """Records start/update/stop so reveal ordering can be asserted with no sleeps.""" + + instances: list[_SpyLive] = [] + + def __init__(self, *args: object, **kwargs: object) -> None: + self.calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = [] + _SpyLive.instances.append(self) + + def start(self) -> None: + self.calls.append(("start", (), {})) + + def update(self, renderable: object, *, refresh: bool = True) -> None: + self.calls.append(("update", (renderable,), {"refresh": refresh})) + + def stop(self) -> None: + self.calls.append(("stop", (), {})) + + +def test_diff_reveal_short_diff_is_noop(monkeypatch: pytest.MonkeyPatch) -> None: + _SpyLive.instances = [] + monkeypatch.setattr(streaming_mod, "Live", _SpyLive) + reveal = DiffReveal(terminal_console(), GLYPHS, enabled=True) + # 5 additions = 5 rendered rows, well under ANIMATE_THRESHOLD. + reveal.reveal(_additions_diff(5), max_rows=DiffReveal.WINDOW_ROWS) + assert _SpyLive.instances == [] # no Live opened for a short diff + + +def test_diff_reveal_disabled_is_noop(monkeypatch: pytest.MonkeyPatch) -> None: + _SpyLive.instances = [] + monkeypatch.setattr(streaming_mod, "Live", _SpyLive) + reveal = DiffReveal(terminal_console(), GLYPHS, enabled=False) + reveal.reveal(_additions_diff(40), max_rows=DiffReveal.WINDOW_ROWS) + assert _SpyLive.instances == [] # motion off → no Live even for a long diff + + +def test_diff_reveal_nontty_is_noop(monkeypatch: pytest.MonkeyPatch) -> None: + _SpyLive.instances = [] + monkeypatch.setattr(streaming_mod, "Live", _SpyLive) + # plain_console() is not a terminal → enabled collapses to False internally. + reveal = DiffReveal(plain_console(), GLYPHS, enabled=True) + reveal.reveal(_additions_diff(40), max_rows=DiffReveal.WINDOW_ROWS) + assert _SpyLive.instances == [] + + +def test_diff_reveal_row_count_is_pure() -> None: + reveal = DiffReveal(terminal_console(), GLYPHS, enabled=True) + diff = _additions_diff(30) + first = reveal.row_count(diff) + second = reveal.row_count(diff) + assert first == second == 30 + assert reveal.row_count("") >= 0 + + +def test_diff_reveal_long_diff_animates_and_clears_before_stop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _SpyLive.instances = [] + monkeypatch.setattr(streaming_mod, "Live", _SpyLive) + monkeypatch.setattr(streaming_mod.time, "sleep", lambda _seconds: None) # no real delay + reveal = DiffReveal(terminal_console(), GLYPHS, enabled=True) + reveal.reveal(_additions_diff(40), max_rows=DiffReveal.WINDOW_ROWS) + + assert len(_SpyLive.instances) == 1 + live = _SpyLive.instances[0] + names = [c[0] for c in live.calls] + assert names[0] == "start" + assert names[-1] == "stop" + # The clearing update must immediately precede stop (mirrors finish()). + stop_index = names.index("stop") + last_update_index = max(i for i, n in enumerate(names) if n == "update") + assert last_update_index == stop_index - 1 + clearing = live.calls[last_update_index] + assert clearing[1][0] == "" + assert clearing[2]["refresh"] is False + + +def test_diff_reveal_chunk_math_bounds_tick_count() -> None: + """The chunk size keeps any diff length within TOTAL_DURATION's tick budget.""" + import math + + ticks_budget = math.floor(DiffReveal.TOTAL_DURATION / streaming_mod._REFRESH_SECONDS) + for total in (21, 40, 60, 200, 500): + chunk = max(1, math.ceil(total / max(1, ticks_budget))) + frames = math.ceil(total / chunk) + assert frames <= ticks_budget, f"{total} rows took {frames} frames > {ticks_budget}" + + def test_runtime_emits_response_hooks_and_turn_stats(tmp_path: Path) -> None: loaded = load_config( user_config_file=tmp_path / "missing-user.toml", diff --git a/tests/test_system_prompt.py b/tests/test_system_prompt.py index c05b38c..66d9af3 100644 --- a/tests/test_system_prompt.py +++ b/tests/test_system_prompt.py @@ -99,7 +99,7 @@ def test_base_prompt_retains_proposal_rules() -> None: def test_prompt_version_bumped() -> None: - assert PROMPT_VERSION == 4 + assert PROMPT_VERSION == 5 def test_prompts_planning_module_has_no_live_content() -> None: @@ -112,8 +112,15 @@ def test_prompts_planning_module_has_no_live_content() -> None: def test_prompt_network_statement_is_accurate() -> None: + # Local (default) session: the "no independent network access" claim is true. prompt = build_system_prompt(workspace=Path("/work"), profile="balanced") assert "no independent network access" in prompt.lower() + # Egressing session: the false "entirely on this machine / no network" claim + # must be dropped and replaced with an honest one (design section 15.2). + remote = build_system_prompt(workspace=Path("/work"), profile="balanced", is_egressing=True) + assert "no independent network access" not in remote.lower() + assert "entirely on this machine" not in remote.lower() + assert "leaves this device" in remote.lower() def test_planning_skill_triggers_are_plan_states() -> None: diff --git a/tests/test_terminal_ui.py b/tests/test_terminal_ui.py index 0f9339d..a66eadf 100644 --- a/tests/test_terminal_ui.py +++ b/tests/test_terminal_ui.py @@ -6,9 +6,16 @@ from collections.abc import Iterator from pathlib import Path +import pytest from rich.console import Console -from shellpilot.cli.terminal import TerminalUI, should_discard_interrupt +from shellpilot.cli.terminal import ( + TerminalUI, + _relative_age, + _resolve_project_agents_trust, + high_stakes_override_notice, + should_discard_interrupt, +) from shellpilot.cli.theme import SHELLPILOT_THEME, UNICODE_GLYPHS from shellpilot.memory.redaction import REDACTED from shellpilot.policy.approvals import ApprovalRequest @@ -67,32 +74,65 @@ def high_request() -> ApprovalRequest: def test_medium_approval_accepts_yes_case_insensitively() -> None: console = make_console() - assert make_ui(console, ["Y"]).ask_approval(medium_request()) is True + reply = make_ui(console, ["Y"]).ask_approval(medium_request()) + assert reply.approved is True + assert reply.steer_text is None out = console.export_text() assert " MEDIUM " in out - assert "[y/n]" in out - assert "[y/N]" not in out + assert "[y]es / [e]dit / [n]o" in out def test_medium_approval_enter_defaults_to_no() -> None: console = make_console() - assert make_ui(console, [""]).ask_approval(medium_request()) is False + reply = make_ui(console, [""]).ask_approval(medium_request()) + assert reply.approved is False + assert reply.steer_text is None def test_high_approval_requires_typed_run() -> None: console = make_console() - assert make_ui(console, ["y"]).ask_approval(high_request()) is False + assert make_ui(console, ["y"]).ask_approval(high_request()).approved is False console2 = make_console() - assert make_ui(console2, ["run"]).ask_approval(high_request()) is True + reply = make_ui(console2, ["run"]).ask_approval(high_request()) + assert reply.approved is True + assert reply.steer_text is None out = console2.export_text() assert " HIGH " in out assert "Removes stale build output." in out +def test_medium_approval_edit_collects_steer_guidance() -> None: + """[e]dit at a normal prompt rejects-and-steers: not approved, guidance captured.""" + console = make_console() + reply = make_ui(console, ["e", "use patch_file instead"]).ask_approval(medium_request()) + assert reply.approved is False + assert reply.steer_text == "use patch_file instead" + out = console.export_text() + assert "Tell the model what to do instead:" in out + + +def test_high_approval_edit_steers_without_running() -> None: + """[e]dit at a HIGH-risk command prompt steers (no run, no typed-'run').""" + console = make_console() + reply = make_ui(console, ["e", "the dir is 'build' not 'bulid', use git clean"]).ask_approval( + high_request() + ) + assert reply.approved is False + assert reply.steer_text == "the dir is 'build' not 'bulid', use git clean" + + +def test_edit_empty_guidance_is_plain_decline() -> None: + """Empty guidance on [e]dit is treated as a plain decline (runs nothing).""" + console = make_console() + reply = make_ui(console, ["e", " "]).ask_approval(medium_request()) + assert reply.approved is False + assert reply.steer_text is None + + def test_approval_renders_diff_panel() -> None: diff = '--- a/hello.py\n+++ b/hello.py\n@@ -1 +1 @@\n-print("done")\n+print("Goodbye World")\n' console = make_console() - make_ui(console, ["y"]).ask_approval(medium_request(diff=diff)) + assert make_ui(console, ["y"]).ask_approval(medium_request(diff=diff)).approved is True out = console.export_text() assert "hello.py" in out assert '+ print("Goodbye World")' in out @@ -145,6 +185,18 @@ def test_tool_call_and_result_lines() -> None: assert f"{GLYPHS.check} 1 addition" in out +@pytest.mark.parametrize("method_name", ["show_status", "show_error", "show_command_output"]) +def test_terminal_text_sinks_sanitize_external_text(method_name: str) -> None: + console = make_console() + ui = make_ui(console, []) + + getattr(ui, method_name)("visible\x1b[2J\x00\ttext\x07\x7f") + + out = console.export_text() + assert not any(char in out for char in "\x1b\x00\x07\x7f\t") + assert "visible" in out and "text" in out + + # --------------------------------------------------------------------------- # Fix 2: show_tool_call redacts secrets in the summary display line # --------------------------------------------------------------------------- @@ -163,12 +215,36 @@ def test_show_tool_call_redacts_secret_in_summary() -> None: def test_show_tool_call_plain_argument_unchanged() -> None: - """A non-secret argument must render unchanged.""" + """A non-path argument must render unchanged.""" console = make_console() ui = make_ui(console, []) - ui.show_tool_call("read_file", {"path": "/tmp/notes.txt"}) + ui.show_tool_call("run_command", {"argv": "echo hi"}) + out = console.export_text() + assert "echo hi" in out + + +def test_show_tool_call_displays_resolved_path_not_spoof(tmp_path: Path) -> None: + """The tool-call line shows the resolved, workspace-relative path, not the + raw (potentially spoofing) model argument.""" + console = make_console() + ui = TerminalUI(console, glyphs=GLYPHS, spinner=False, workspace=tmp_path) + ui.show_tool_call("read_file", {"path": "notes/../secret.txt"}) + out = console.export_text() + assert "notes/../secret.txt" not in out + assert "secret.txt" in out + + +def test_show_tool_call_marks_path_escaping_workspace(tmp_path: Path) -> None: + """A path that resolves outside the workspace renders an honest marker, not + a fabricated-looking in-workspace path.""" + from shellpilot.tools.base import OUTSIDE_WORKSPACE_DISPLAY + + console = make_console() + ui = TerminalUI(console, glyphs=GLYPHS, spinner=False, workspace=tmp_path) + ui.show_tool_call("read_file", {"path": "../outside.txt"}) out = console.export_text() - assert "/tmp/notes.txt" in out + assert "../outside.txt" not in out + assert OUTSIDE_WORKSPACE_DISPLAY in out # --------------------------------------------------------------------------- @@ -238,6 +314,15 @@ def test_tool_call_starts_labeled_spinner_and_result_stops_it() -> None: assert spy.stops >= 1 +def test_tool_call_sanitizes_spinner_label() -> None: + console = make_console() + ui, spy = _ui_with_recording_spinner(console, []) + + ui.show_tool_call("visible\x1b[2J\x00text\x07", {}) + + assert spy.started_labels == ["running visible[2Jtext"] + + def test_approval_stops_spinner_before_input() -> None: """ask_approval stops the spinner before prompting the user.""" console = make_console() @@ -313,3 +398,389 @@ def test_custom_window_seconds() -> None: should_discard_interrupt(turn_just_ran=True, elapsed_seconds=0.06, window_seconds=0.05) is False ) + + +def make_trust_console(answers: list[str]) -> Console: + console = make_console() + answer_iter: Iterator[str] = iter(answers) + + def fake_input(prompt: str = "", **kwargs: object) -> str: + console.print(prompt, end="") + return next(answer_iter) + + console.input = fake_input # type: ignore[method-assign] + return console + + +def test_trust_no_project_agents_md_returns_true(tmp_path: Path) -> None: + console = make_console() + assert _resolve_project_agents_trust(console, tmp_path, tty=True) is True + + +def test_trust_already_trusted_digest_no_prompt(tmp_path: Path) -> None: + from shellpilot.memory.agents_md import project_agents_md_digest + from shellpilot.persistence.workspace_state import save_trusted_agents_digest + + (tmp_path / "AGENTS.md").write_text("Project rules.", encoding="utf-8") + digest = project_agents_md_digest(tmp_path) + assert digest is not None + save_trusted_agents_digest(tmp_path, digest) + # No input wired: if it tried to prompt, this would raise StopIteration. + console = make_trust_console([]) + assert _resolve_project_agents_trust(console, tmp_path, tty=True) is True + + +def test_trust_non_tty_fails_closed(tmp_path: Path) -> None: + (tmp_path / "AGENTS.md").write_text("Project rules.", encoding="utf-8") + console = make_console() + assert _resolve_project_agents_trust(console, tmp_path, tty=False) is False + assert "not loaded" in console.export_text() + + +def test_trust_accept_records_digest(tmp_path: Path) -> None: + from shellpilot.memory.agents_md import project_agents_md_digest + from shellpilot.persistence.workspace_state import load_trusted_agents_digest + + (tmp_path / "AGENTS.md").write_text("Project rules.", encoding="utf-8") + console = make_trust_console(["y"]) + assert _resolve_project_agents_trust(console, tmp_path, tty=True) is True + assert load_trusted_agents_digest(tmp_path) == project_agents_md_digest(tmp_path) + + +def test_trust_decline_does_not_record(tmp_path: Path) -> None: + from shellpilot.persistence.workspace_state import load_trusted_agents_digest + + (tmp_path / "AGENTS.md").write_text("Project rules.", encoding="utf-8") + console = make_trust_console(["n"]) + assert _resolve_project_agents_trust(console, tmp_path, tty=True) is False + assert load_trusted_agents_digest(tmp_path) is None + + +def test_trust_changed_content_reprompts(tmp_path: Path) -> None: + from shellpilot.memory.agents_md import project_agents_md_digest + from shellpilot.persistence.workspace_state import save_trusted_agents_digest + + agents = tmp_path / "AGENTS.md" + agents.write_text("Original rules.", encoding="utf-8") + save_trusted_agents_digest(tmp_path, project_agents_md_digest(tmp_path) or "") + agents.write_text("Tampered rules.", encoding="utf-8") + console = make_trust_console(["n"]) + assert _resolve_project_agents_trust(console, tmp_path, tty=True) is False + assert "changed since" in console.export_text() + + +def test_trust_eof_declines(tmp_path: Path) -> None: + (tmp_path / "AGENTS.md").write_text("Project rules.", encoding="utf-8") + console = make_console() + + def raise_eof(prompt: str = "", **kwargs: object) -> str: + raise EOFError + + console.input = raise_eof # type: ignore[method-assign] + assert _resolve_project_agents_trust(console, tmp_path, tty=True) is False + + +# --------------------------------------------------------------------------- +# Cloud-egress consent gate (v0.10.0 Part 2): per-session y/N, fail-closed. +# --------------------------------------------------------------------------- + + +def _settings(*, allow_cloud: bool = False, base_url: str = "http://localhost:11434"): + from shellpilot.config.model import ModelSettings, Settings + + return Settings(model=ModelSettings(allow_cloud=allow_cloud, base_url=base_url)) + + +def test_consent_local_model_no_prompt(tmp_path: Path) -> None: + """A local model on localhost is non-egressing → proceed, NO prompt shown.""" + from shellpilot.cli.terminal import _resolve_cloud_consent + + # No input wired: a prompt would raise StopIteration. + console = make_trust_console([]) + assert _resolve_cloud_consent(console, _settings(), "gemma4:e4b", tty=True) is True + assert console.export_text().strip() == "" + + +def test_consent_cloud_model_allow_off_rejects(tmp_path: Path) -> None: + """A cloud model with allow_cloud off is refused with a clear message — no prompt.""" + from shellpilot.cli.terminal import _resolve_cloud_consent + + console = make_trust_console([]) + assert ( + _resolve_cloud_consent( + console, _settings(allow_cloud=False), "nemotron-3-nano:30b-cloud", tty=True + ) + is False + ) + out = console.export_text() + assert "allow_cloud" in out + + +def test_consent_cloud_model_non_tty_fails_closed(tmp_path: Path) -> None: + """allow_cloud on but non-interactive → fail closed (no egress without consent).""" + from shellpilot.cli.terminal import _resolve_cloud_consent + + console = make_trust_console([]) + assert ( + _resolve_cloud_consent( + console, _settings(allow_cloud=True), "nemotron-3-nano:30b-cloud", tty=False + ) + is False + ) + assert "non-interactive" in console.export_text() + + +def test_consent_cloud_model_accepts_yes(tmp_path: Path) -> None: + """allow_cloud on + tty + 'y' → proceed; the disclosure text is shown.""" + from shellpilot.cli.terminal import _resolve_cloud_consent + + console = make_trust_console(["y"]) + assert ( + _resolve_cloud_consent( + console, _settings(allow_cloud=True), "nemotron-3-nano:30b-cloud", tty=True + ) + is True + ) + out = console.export_text() + # The disclosure must be honest about what leaves the device. + assert "remote" in out.lower() or "leaves" in out.lower() + + +def test_consent_cloud_model_enter_declines(tmp_path: Path) -> None: + """Default No: a bare Enter declines (fail closed).""" + from shellpilot.cli.terminal import _resolve_cloud_consent + + console = make_trust_console([""]) + assert ( + _resolve_cloud_consent( + console, _settings(allow_cloud=True), "nemotron-3-nano:30b-cloud", tty=True + ) + is False + ) + + +def test_consent_cloud_model_explicit_no_declines(tmp_path: Path) -> None: + from shellpilot.cli.terminal import _resolve_cloud_consent + + console = make_trust_console(["n"]) + assert ( + _resolve_cloud_consent( + console, _settings(allow_cloud=True), "nemotron-3-nano:30b-cloud", tty=True + ) + is False + ) + + +def test_consent_cloud_model_eof_declines(tmp_path: Path) -> None: + """EOF at the consent prompt fails closed.""" + from shellpilot.cli.terminal import _resolve_cloud_consent + + console = make_console() + + def raise_eof(prompt: str = "", **kwargs: object) -> str: + raise EOFError + + console.input = raise_eof # type: ignore[method-assign] + assert ( + _resolve_cloud_consent( + console, _settings(allow_cloud=True), "nemotron-3-nano:30b-cloud", tty=True + ) + is False + ) + + +def test_consent_remote_base_url_local_model_requires_consent(tmp_path: Path) -> None: + """A non-loopback base_url egresses even for a local-looking model name.""" + from shellpilot.cli.terminal import _resolve_cloud_consent + + settings = _settings(allow_cloud=True, base_url="https://ollama.com") + console = make_trust_console([""]) + assert _resolve_cloud_consent(console, settings, "gemma4:e4b", tty=True) is False + # And with allow_cloud off it is refused before any prompt. + settings_off = _settings(allow_cloud=False, base_url="https://ollama.com") + console2 = make_trust_console([]) + assert _resolve_cloud_consent(console2, settings_off, "gemma4:e4b", tty=True) is False + assert "allow_cloud" in console2.export_text() + + +# --------------------------------------------------------------------------- +# P4-B review nit #9: the streamlined one-key cloud-confirm path MUST reach the +# cloud consent gate. There is no other end-to-end guard that the picker's +# Enter-to-fly-the-last-model shortcut routes a cloud model through +# _resolve_cloud_consent before any model-touching call — a regression a future +# picker refactor could silently reintroduce. This drives run_interactive +# through that exact path: a cloud last_model, confirmed via the one-key Enter +# shortcut, with consent stubbed to refuse, and asserts (a) consent WAS invoked +# for the chosen cloud model and (b) the run aborts BEFORE client.preload (the +# first egress point), so nothing touched the model. +# --------------------------------------------------------------------------- + + +def test_one_key_cloud_confirm_reaches_consent_gate_before_preload( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + import shellpilot.cli.terminal as terminal_mod + from shellpilot.llm.ollama import LocalModel + + cloud_model = "nemotron-3-nano:30b-cloud" + + class _FakeClient: + """Minimal OllamaClient double for the boot-path picker/consent seam.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + self.preload_calls: list[str] = [] + + def health(self) -> bool: + return True + + def list_models(self) -> list[LocalModel]: + # The cloud model appears in /api/tags here so confirm_last_model's + # Enter-to-fly path selects it without opening the menu. + return [LocalModel(name=cloud_model, size_bytes=1)] + + def preload(self, model: str, *, keep_alive: str = "5m") -> None: + self.preload_calls.append(model) + + fake_client = _FakeClient() + monkeypatch.setattr(terminal_mod, "OllamaClient", lambda *a, **k: fake_client) + + # Force the picker to show and take the one-key Enter ("fly the last model") + # shortcut for the cloud last_model — no menu, single confirm keystroke. + monkeypatch.setattr(terminal_mod, "should_show_picker", lambda **kwargs: True) + monkeypatch.setattr(terminal_mod, "load_last_model", lambda workspace: cloud_model) + monkeypatch.setattr(terminal_mod, "save_last_model", lambda workspace, chosen: None) + monkeypatch.setattr(terminal_mod, "confirm_last_model", lambda console, last: True) + + def _fail_choose_model(*args: object, **kwargs: object) -> str: + raise AssertionError("the full menu must not open on the one-key Enter path") + + monkeypatch.setattr(terminal_mod, "choose_model", _fail_choose_model) + + # Stub the consent gate to record the model it was asked about and refuse, + # so the boot must abort at the consent boundary. + consent_calls: list[str] = [] + + def _record_consent(console: object, settings: object, chosen: str, *, tty: bool) -> bool: + consent_calls.append(chosen) + return False + + monkeypatch.setattr(terminal_mod, "_resolve_cloud_consent", _record_consent) + + # Make the boot path believe it is an interactive TTY. + monkeypatch.setattr(terminal_mod.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(Console, "is_terminal", property(lambda self: True)) + + rc = terminal_mod.run_interactive(tmp_path) + + assert consent_calls == [cloud_model], "consent gate not reached for the chosen cloud model" + assert fake_client.preload_calls == [], "the model was touched despite refused consent" + assert rc == 1, "a refused cloud consent must abort the boot" + + +def test_high_stakes_override_notice_lists_active_egress_overrides(tmp_path: Path) -> None: + """A persisted high-stakes override surfaces an amber notice every boot.""" + from shellpilot.config.overrides import overrides_path, save_overrides + + save_overrides( + overrides_path(tmp_path), + {"model.allow_cloud": True, "tools.web": True, "runtime.max_tool_turns": 20}, + ) + notice = high_stakes_override_notice(tmp_path) + assert notice is not None + assert "model.allow_cloud=True" in notice + assert "tools.web=True" in notice + # Non-high-stakes overrides are not surfaced by this notice. + assert "max_tool_turns" not in notice + assert "/config unset" in notice + + +def test_high_stakes_override_notice_none_when_clean(tmp_path: Path) -> None: + """No high-stakes override → no notice (default boot prints nothing).""" + from shellpilot.config.overrides import overrides_path, save_overrides + + # No overrides file at all. + assert high_stakes_override_notice(tmp_path) is None + # An overrides file with only ordinary keys also yields no notice. + save_overrides(overrides_path(tmp_path), {"runtime.max_tool_turns": 20}) + assert high_stakes_override_notice(tmp_path) is None + + +def test_relative_age_buckets() -> None: + now = 1_000_000.0 + assert _relative_age(now - 10, now=now) == "just now" + assert _relative_age(now - 39 * 60, now=now) == "39m ago" + assert _relative_age(now - 2 * 3600, now=now) == "2h ago" + assert _relative_age(now - 3 * 86400, now=now) == "3d ago" + # Future / clock skew never goes negative. + assert _relative_age(now + 100, now=now) == "just now" + + +def test_bang_prefix_runs_manual_shell_not_model( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """`!` routes to the audited manual-shell path and is never sent to the model.""" + import shellpilot.cli.terminal as terminal_mod + from shellpilot.llm.ollama import LocalModel + from shellpilot.persistence.paths import AppPaths + + model = "gemma4:e4b" + + class _FakeClient: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def health(self) -> bool: + return True + + def list_models(self) -> list[LocalModel]: + return [LocalModel(name=model, size_bytes=1)] + + def preload(self, name: str, *, keep_alive: str = "5m") -> None: + pass + + def model_context_length(self, name: str) -> int: + return 8192 + + monkeypatch.setattr(terminal_mod, "OllamaClient", lambda *a, **k: _FakeClient()) + # Redirect app dirs to tmp so boot-time audit/session/memory never touch real state. + fake_paths = AppPaths( + config_dir=tmp_path / "cfg", + data_dir=tmp_path / "data", + state_dir=tmp_path / "state", + cache_dir=tmp_path / "cache", + ) + monkeypatch.setattr(terminal_mod.AppPaths, "default", classmethod(lambda cls: fake_paths)) + + # Feed one `!` line, then EOF to end the REPL. + class _Reader: + def __init__(self) -> None: + self._lines = iter(["!echo hi"]) + + def read(self, context: object) -> str: + try: + return next(self._lines) + except StopIteration: + raise EOFError from None + + monkeypatch.setattr(terminal_mod, "make_input", lambda *a, **k: _Reader()) + + bang_calls: list[str] = [] + monkeypatch.setattr( + terminal_mod, + "run_manual_command", + lambda command, cwd, audit: bang_calls.append(command) or 0, + ) + model_turns: list[str] = [] + monkeypatch.setattr( + terminal_mod.ConversationRuntime, + "run_turn", + lambda self, text, **k: model_turns.append(text) or "", + ) + monkeypatch.setattr(terminal_mod.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(Console, "is_terminal", property(lambda self: True)) + + rc = terminal_mod.run_interactive(tmp_path, model_override=model) + + assert bang_calls == ["echo hi"], "'!' must run via the manual-shell path" + assert model_turns == [], "a '!' line must NOT be sent to the model" + assert rc == 0 diff --git a/tests/test_tools_readonly.py b/tests/test_tools_readonly.py index 51bf9ad..eb5a653 100644 --- a/tests/test_tools_readonly.py +++ b/tests/test_tools_readonly.py @@ -6,7 +6,7 @@ import pytest from shellpilot.llm.messages import ToolCall -from shellpilot.policy.approvals import ApprovalRequest +from shellpilot.policy.approvals import APPROVE, DECLINE, ApprovalReply, ApprovalRequest from shellpilot.policy.risk import RiskLevel from shellpilot.runtime.executor import ToolExecutor from shellpilot.tools.base import ToolContext, WorkspaceBoundaryError, resolve_in_workspace @@ -185,9 +185,9 @@ def __init__(self, approve: bool) -> None: self.approve = approve self.requests: list[ApprovalRequest] = [] - def __call__(self, request: ApprovalRequest) -> bool: + def __call__(self, request: ApprovalRequest) -> ApprovalReply: self.requests.append(request) - return self.approve + return APPROVE if self.approve else DECLINE def _executor( diff --git a/tests/test_web_fetch.py b/tests/test_web_fetch.py index 94f02b3..64ccf43 100644 --- a/tests/test_web_fetch.py +++ b/tests/test_web_fetch.py @@ -51,6 +51,24 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.MockTransport(handler) +def _patch_getaddrinfo(monkeypatch: pytest.MonkeyPatch, fetch_mod: object, *addresses: str) -> None: + """Patch socket.getaddrinfo (as imported by fetch) to resolve to *addresses*. + + Each address is returned as one getaddrinfo entry whose sockaddr[0] is the + IP string, matching the (family, type, proto, canonname, sockaddr) tuple + shape the resolver returns. Pass no addresses to simulate an unresolvable + name (raises socket.gaierror). + """ + import socket + + def fake_getaddrinfo(host: str, *args: object, **kwargs: object) -> list[tuple[object, ...]]: + if not addresses: + raise socket.gaierror("name resolution failed") + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (addr, 0)) for addr in addresses] + + monkeypatch.setattr(fetch_mod.socket, "getaddrinfo", fake_getaddrinfo) + + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -257,10 +275,13 @@ def handler(request: httpx.Request) -> httpx.Response: assert calls == [], f"Transport was called for blocked URLs: {[r.url for r in calls]}" -def test_allows_normal_public_hosts() -> None: - """Public hostnames must pass the URL guard (no network involved).""" +def test_allows_normal_public_hosts(monkeypatch: pytest.MonkeyPatch) -> None: + """Public hostnames must pass the URL guard when they resolve to public IPs.""" + from shellpilot.web import fetch as fetch_mod from shellpilot.web.fetch import _check_url + _patch_getaddrinfo(monkeypatch, fetch_mod, "93.184.216.34") + # These must not raise _check_url("https://example.com/") _check_url("https://pypi.org/simple/") @@ -365,10 +386,13 @@ def test_rejects_multi_trailing_dots() -> None: _check_url("http://localhost../") -def test_allows_public_domain_with_trailing_dot() -> None: +def test_allows_public_domain_with_trailing_dot(monkeypatch: pytest.MonkeyPatch) -> None: """http://example.com./ (FQDN with trailing dot) must still be allowed.""" + from shellpilot.web import fetch as fetch_mod from shellpilot.web.fetch import _check_url + _patch_getaddrinfo(monkeypatch, fetch_mod, "93.184.216.34") + # Must not raise — public domain, trailing dot is a valid DNS encoding _check_url("http://example.com./") @@ -392,3 +416,192 @@ def test_existing_blocks_unaffected_by_trailing_dot_change() -> None: for url in blocked: with pytest.raises(WebFetchError): _check_url(url) + + +# --------------------------------------------------------------------------- +# DNS resolve-and-validate (F11 — DNS-rebinding SSRF) +# --------------------------------------------------------------------------- + + +def test_name_resolving_to_metadata_ip_blocked(monkeypatch: pytest.MonkeyPatch) -> None: + """A public-looking name resolving to the cloud-metadata IP must be blocked.""" + from shellpilot.web import fetch as fetch_mod + from shellpilot.web.fetch import _check_url + + _patch_getaddrinfo(monkeypatch, fetch_mod, "169.254.169.254") + + with pytest.raises(WebFetchError, match="non-public address"): + _check_url("http://metadata.evil.example/") + + +def test_name_resolving_to_loopback_blocked(monkeypatch: pytest.MonkeyPatch) -> None: + """A public-looking name resolving to loopback must be blocked.""" + from shellpilot.web import fetch as fetch_mod + from shellpilot.web.fetch import _check_url + + _patch_getaddrinfo(monkeypatch, fetch_mod, "127.0.0.1") + + with pytest.raises(WebFetchError, match="non-public address"): + _check_url("http://rebind.example/") + + +def test_name_resolving_to_private_blocked(monkeypatch: pytest.MonkeyPatch) -> None: + """A public-looking name resolving to an RFC-1918 private IP must be blocked.""" + from shellpilot.web import fetch as fetch_mod + from shellpilot.web.fetch import _check_url + + _patch_getaddrinfo(monkeypatch, fetch_mod, "10.0.0.5") + + with pytest.raises(WebFetchError, match="non-public address"): + _check_url("http://intranet.example/") + + +def test_name_resolving_to_cgnat_blocked(monkeypatch: pytest.MonkeyPatch) -> None: + """A name resolving into CGNAT 100.64.0.0/10 (Alibaba/Oracle metadata) must be blocked.""" + from shellpilot.web import fetch as fetch_mod + from shellpilot.web.fetch import _check_url + + _patch_getaddrinfo(monkeypatch, fetch_mod, "100.100.100.200") + + with pytest.raises(WebFetchError, match="non-public address"): + _check_url("http://cloud-meta.example/") + + +def test_name_resolving_to_public_ip_allowed(monkeypatch: pytest.MonkeyPatch) -> None: + """A name resolving only to a public IP must pass.""" + from shellpilot.web import fetch as fetch_mod + from shellpilot.web.fetch import _check_url + + _patch_getaddrinfo(monkeypatch, fetch_mod, "93.184.216.34") + + # Must not raise + _check_url("https://example.com/") + + +def test_name_with_mixed_addresses_blocked_on_any_private( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If a name resolves to a public AND a private IP, it must still be blocked.""" + from shellpilot.web import fetch as fetch_mod + from shellpilot.web.fetch import _check_url + + _patch_getaddrinfo(monkeypatch, fetch_mod, "93.184.216.34", "127.0.0.1") + + with pytest.raises(WebFetchError, match="non-public address"): + _check_url("http://mixed.example/") + + +def test_unresolvable_name_does_not_raise(monkeypatch: pytest.MonkeyPatch) -> None: + """An unresolvable name must NOT raise from the guard — the fetch fails naturally.""" + from shellpilot.web import fetch as fetch_mod + from shellpilot.web.fetch import _check_url + + _patch_getaddrinfo(monkeypatch, fetch_mod) # no addresses → gaierror + + # Must not raise — the connection attempt will fail cleanly later. + _check_url("http://does-not-exist.invalid/") + + +def test_name_resolving_to_ipv6_loopback_blocked(monkeypatch: pytest.MonkeyPatch) -> None: + """IPv6 resolution (4-tuple sockaddr) is validated like IPv4.""" + import socket + + from shellpilot.web import fetch as fetch_mod + from shellpilot.web.fetch import _check_url + + def fake(host: str, *args: object, **kwargs: object) -> list[tuple[object, ...]]: + return [(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::1", 0, 0, 0))] + + monkeypatch.setattr(fetch_mod.socket, "getaddrinfo", fake) + with pytest.raises(WebFetchError, match="non-public address"): + _check_url("http://ipv6-rebind.example/") + + +def test_name_resolving_to_ipv6_public_allowed(monkeypatch: pytest.MonkeyPatch) -> None: + """A name resolving only to a public IPv6 address must pass.""" + import socket + + from shellpilot.web import fetch as fetch_mod + from shellpilot.web.fetch import _check_url + + pub = "2606:2800:220:1:248:1893:25c8:1946" + + def fake(host: str, *args: object, **kwargs: object) -> list[tuple[object, ...]]: + return [(socket.AF_INET6, socket.SOCK_STREAM, 6, "", (pub, 0, 0, 0))] + + monkeypatch.setattr(fetch_mod.socket, "getaddrinfo", fake) + _check_url("https://ipv6.example/") # must not raise + + +def test_unencodable_idna_hostname_does_not_raise(monkeypatch: pytest.MonkeyPatch) -> None: + """An un-encodable IDNA hostname fails closed; no raw UnicodeError escapes.""" + from shellpilot.web import fetch as fetch_mod + from shellpilot.web.fetch import _check_url + + def raise_unicode(host: str, *args: object, **kwargs: object) -> list[tuple[object, ...]]: + raise UnicodeError("label too long") + + monkeypatch.setattr(fetch_mod.socket, "getaddrinfo", raise_unicode) + _check_url("http://xn--very-long-label.example/") # must not raise + + +def test_cgnat_ip_literal_blocked() -> None: + """A CGNAT 100.64.0.0/10 literal must be blocked under the is_global switch.""" + from shellpilot.web.fetch import _check_url + + with pytest.raises(WebFetchError): + _check_url("http://100.64.0.1/") + + +def test_public_ip_literal_allowed() -> None: + """A public IP literal must pass without any DNS resolution.""" + from shellpilot.web.fetch import _check_url + + # Must not raise (no getaddrinfo patch needed — it's a literal, not a name). + _check_url("http://93.184.216.34/") + + +def test_redirect_to_private_resolving_name_blocked( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A redirect to a public-looking name that resolves private must be blocked at the hop.""" + from shellpilot.web import fetch as fetch_mod + + _patch_getaddrinfo(monkeypatch, fetch_mod, "127.0.0.1") + + calls: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + # First hop redirects to a public-looking name that resolves to loopback. + return httpx.Response( + 302, + headers={ + "location": "http://rebind.example/secret", + "content-type": "text/html", + }, + content=b"", + ) + + # The initial host must resolve public so the first hop is permitted. + fetcher = PageFetcher(transport=httpx.MockTransport(handler)) + + # The first request needs a public-resolving host; patch the initial host too + # by giving getaddrinfo a public IP for it but private for the redirect host. + import socket + + def selective_getaddrinfo( + host: str, *args: object, **kwargs: object + ) -> list[tuple[object, ...]]: + ip = "127.0.0.1" if host == "rebind.example" else "93.184.216.34" + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 0))] + + monkeypatch.setattr(fetch_mod.socket, "getaddrinfo", selective_getaddrinfo) + + with pytest.raises(WebFetchError, match="non-public address"): + fetcher.fetch("http://start.example/a") + + # Only the first request should have been made; the rebind hop is blocked. + assert len(calls) == 1, ( + f"Expected exactly 1 transport call, got {len(calls)}: {[str(r.url) for r in calls]}" + ) diff --git a/tests/test_workspace_state.py b/tests/test_workspace_state.py index c12fbc9..dce835a 100644 --- a/tests/test_workspace_state.py +++ b/tests/test_workspace_state.py @@ -2,7 +2,13 @@ from pathlib import Path -from shellpilot.persistence.workspace_state import load_last_model, save_last_model, state_path +from shellpilot.persistence.workspace_state import ( + load_last_model, + load_trusted_agents_digest, + save_last_model, + save_trusted_agents_digest, + state_path, +) def test_state_file_lives_under_dot_shellpilot(tmp_path: Path) -> None: @@ -51,3 +57,41 @@ def test_save_creates_directory(tmp_path: Path) -> None: workspace.mkdir() save_last_model(workspace, "gemma4:e2b") assert state_path(workspace).is_file() + + +def test_trusted_agents_digest_roundtrip(tmp_path: Path) -> None: + save_trusted_agents_digest(tmp_path, "abc123") + assert load_trusted_agents_digest(tmp_path) == "abc123" + + +def test_load_trusted_digest_missing_returns_none(tmp_path: Path) -> None: + assert load_trusted_agents_digest(tmp_path) is None + + +def test_save_model_then_digest_keeps_both(tmp_path: Path) -> None: + save_last_model(tmp_path, "gemma4:e4b") + save_trusted_agents_digest(tmp_path, "deadbeef") + assert load_last_model(tmp_path) == "gemma4:e4b" + assert load_trusted_agents_digest(tmp_path) == "deadbeef" + + +def test_save_digest_then_model_keeps_both(tmp_path: Path) -> None: + save_trusted_agents_digest(tmp_path, "deadbeef") + save_last_model(tmp_path, "gemma4:e4b") + assert load_trusted_agents_digest(tmp_path) == "deadbeef" + assert load_last_model(tmp_path) == "gemma4:e4b" + + +def test_old_state_without_digest_key(tmp_path: Path) -> None: + path = state_path(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('{"version": 1, "last_model": "gemma4:e4b"}\n', encoding="utf-8") + assert load_trusted_agents_digest(tmp_path) is None + assert load_last_model(tmp_path) == "gemma4:e4b" + + +def test_load_non_string_digest_returns_none(tmp_path: Path) -> None: + path = state_path(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('{"version": 1, "trusted_agents_md": 42}\n', encoding="utf-8") + assert load_trusted_agents_digest(tmp_path) is None diff --git a/tests/test_write_tools.py b/tests/test_write_tools.py index 4826798..1cab01b 100644 --- a/tests/test_write_tools.py +++ b/tests/test_write_tools.py @@ -157,6 +157,46 @@ def test_patch_after_read_succeeds_and_preserves_rest(tmp_path: Path) -> None: assert "-b = 2" in result.metadata["diff"] +def test_diff_header_shows_resolved_relative_path_not_spoof(tmp_path: Path) -> None: + """The diff header (which becomes the approval panel title) names the + resolved, workspace-relative target — directory included — for a spoofing + path, so the displayed file matches the file actually written.""" + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "f.py").write_text("a = 1\n") + spoof = "sub/../sub/f.py" # resolves to sub/f.py + context = read_then_ctx(tmp_path, spoof) + result = PATCH_FILE.handler( + context, {"path": spoof, "operation": "replace_exact", "old": "a = 1", "new": "a = 2"} + ) + assert result.success + diff = result.metadata["diff"] + # Full resolved relative path is shown, not just the basename, and never the + # raw spoofing string. + assert "+++ b/sub/f.py" in diff + assert "+++ b/f.py" not in diff + assert "sub/../sub" not in diff + + +def test_write_preview_diff_header_uses_resolved_relative_path(tmp_path: Path) -> None: + """The write preview shown at the approval gate carries the resolved, + workspace-relative path in its diff header. + + The target lives in a subdirectory so the resolved relative path + (``sub/new.txt``) differs from its basename (``new.txt``) — this is what + makes the test genuinely guard against the old basename-only header. + """ + from shellpilot.tools.patch import WRITE_FILE + + (tmp_path / "sub").mkdir() + diff = WRITE_FILE.preview( # type: ignore[misc] + ctx(tmp_path), + {"path": "sub/../sub/new.txt", "content": "hello\n", "mode": "create"}, + ) + assert "+++ b/sub/new.txt" in diff + assert "+++ b/new.txt" not in diff # would be the old basename-only header + assert "sub/../sub/new.txt" not in diff + + def test_stale_snapshot_rejected(tmp_path: Path) -> None: (tmp_path / "f.py").write_text("x = 1\n") context = read_then_ctx(tmp_path, "f.py")