Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4af626f
fix(security): escalate git mutating verbs and option-encoded paths o…
lavindeep Jun 27, 2026
7a17b9a
fix(security): hard-bound command output per read
lavindeep Jun 27, 2026
389e528
fix(security): stop ambient proxy env from rerouting httpx traffic
lavindeep Jun 27, 2026
131c68f
fix(security): redact key-named and JSON-shaped secrets
lavindeep Jun 27, 2026
1aa3212
fix(planner): finalize on skipped final step; pin artifact path to pl…
lavindeep Jun 27, 2026
87bfbc6
fix(security): reject truncated Ollama streams; guard session-id read…
lavindeep Jun 27, 2026
0fb2faf
fix(cli): guard slash/manual-shell turns and validate --resume before…
lavindeep Jun 27, 2026
25f9909
fix(security): hide in-workspace secrets in write/patch previews; reb…
lavindeep Jun 27, 2026
a2f21fa
fix(security): validate uddg redirect scheme, clamp max_results, floo…
lavindeep Jun 27, 2026
0771042
docs: reconcile DESIGN with the CLI interrupt and resume fixes
lavindeep Jun 27, 2026
1b8d920
Merge fix/logic-fixes-map: resolve 18 external-review logic and secur…
lavindeep Jun 27, 2026
1a56a3f
refactor: remove dead code (superseded renderers, test-only wrapper, …
lavindeep Jun 27, 2026
8d387f5
refactor(skills): dedupe loader.py path/traversable function pairs
lavindeep Jun 27, 2026
bfa1639
refactor(tools): centralize ALL_PROFILES in tools/base.py
lavindeep Jun 27, 2026
a325e97
refactor(persistence): drop unused FileSnapshot fields
lavindeep Jun 27, 2026
bbbd9b6
refactor(cli): import and constant hygiene
lavindeep Jun 27, 2026
80569d1
refactor(runtime): hoist invariant compaction estimate; dedupe skill …
lavindeep Jun 27, 2026
7396ec5
refactor(llm,web): dedupe /api/show probe and collapse dead imports
lavindeep Jun 27, 2026
45705de
refactor(cli,persistence): single-stat recent(); drop DiffReveal.row_…
lavindeep Jun 27, 2026
7ba06e9
refactor(policy): remove the self-cancelling git stash classifier pair
lavindeep Jun 27, 2026
b6d2dee
Merge refactor/optimizations: behavior-preserving cleanups
lavindeep Jun 27, 2026
d2fb521
fix(security): redact prefixed secret keys in structured redaction
lavindeep Jun 27, 2026
2dfefd7
test(banner): render at a width that can't trigger console-width wrap
lavindeep Jun 27, 2026
09f5106
fix(command): hard-bound captured output to max_capture_chars
lavindeep Jun 27, 2026
1a946dd
fix(ollama): typed errors on malformed or wrong-shaped responses
lavindeep Jun 27, 2026
23cafd4
fix(web): don't flag exact-byte-limit fetches as truncated
lavindeep Jun 27, 2026
3fa084c
test(banner): assert tips against flattened right column (wrap-indepe…
lavindeep Jun 27, 2026
2e2d7d7
fix(ollama): typed errors on malformed streaming chunk shapes
lavindeep Jun 27, 2026
af5521f
chore(release): v0.10.1
lavindeep Jun 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ 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). 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:
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.1 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
~/my-project · gemma4:e4b · balanced · ● local 6% ctx
Expand Down Expand Up @@ -284,14 +284,16 @@ python scripts/benchmark_model.py --model gemma4:e4b --trials 10

## Status and roadmap

Current release: **v0.10.0** — opt-in cloud models. Recent milestones:
Current release: **v0.10.1** — post-v0.10.0 hardening and cleanup. 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.

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`.
- **v0.10.1** — post-v0.10.0 hardening and cleanup: a wave of external-review logic and security fixes (classifier escalation of git mutating verbs and option-encoded paths, hard-bounded command output, `trust_env=False` on every HTTP client, broader secret redaction including prefixed and JSON-shaped keys, planner stuck-state and artifact-path fixes, Ollama stream done-sentinel and typed errors on malformed responses, session-id traversal guard, exact-byte web-fetch boundary) plus behavior-preserving internal cleanups. No model-facing behavior change — a default session is unchanged.

Next up is 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

Expand Down
48 changes: 36 additions & 12 deletions docs/DESIGN.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion shellpilot/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""ShellPilot: a local-first AI shell harness."""

__version__ = "0.10.0"
__version__ = "0.10.1"
2 changes: 1 addition & 1 deletion shellpilot/cli/attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def load_image(path: Path) -> ImageRef:
)
sha256 = hashlib.sha256(raw).hexdigest()
data_b64 = base64.b64encode(raw).decode()
return ImageRef(path=str(path), sha256=sha256, data_b64=data_b64)
return ImageRef(path=str(path), sha256=sha256, data_b64=data_b64, size_bytes=len(raw))


@dataclass
Expand Down
25 changes: 11 additions & 14 deletions shellpilot/cli/banner.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from shellpilot import __version__
from shellpilot.cli.render import _sanitize_line
from shellpilot.cli.theme import COLOR_ACCENT, COLOR_DIM, COLOR_FAINT, COLOR_WARN

# ---------------------------------------------------------------------------
# Jet art — locked v2 block art, embedded verbatim (do NOT read from disk).
Expand Down Expand Up @@ -68,12 +69,8 @@

_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
# Banner-only color constants (shared theme colors imported from theme.py above).
_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
Expand Down Expand Up @@ -131,7 +128,7 @@ def _build_jet() -> Text:


def _build_left_col(model: str, *, is_cloud: bool, profile: str) -> Group:
model_style = f"bold {_COLOR_WARN if is_cloud else _COLOR_ACCENT}"
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
Expand All @@ -142,7 +139,7 @@ def _build_left_col(model: str, *, is_cloud: bool, profile: str) -> Group:
Align.center(_build_jet(), width=_JET_WIDTH),
Text(""),
Text(model, style=model_style, justify="center"),
Text(f"{profile} · {locality}", style=_COLOR_DIM, justify="center"),
Text(f"{profile} · {locality}", style=COLOR_DIM, justify="center"),
)


Expand All @@ -158,24 +155,24 @@ def _section(header: str, items: Sequence[tuple[str, str]]) -> Text:
for lead, desc in items:
t.append("\n")
t.append(f"{lead:<{_ITEM_PAD}}", style="bold")
t.append(desc, style=_COLOR_DIM)
t.append(desc, style=COLOR_DIM)
return t


def _rule() -> Text:
return Text("─" * _RULE_WIDTH, style=_COLOR_FAINT)
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)
t.append(" · ".join(skills), style=COLOR_DIM)
else:
t.append(" · ".join(_AVAILABLE_WORKFLOW_SKILLS), style=_COLOR_DIM)
t.append(" · ".join(_AVAILABLE_WORKFLOW_SKILLS), style=COLOR_DIM)
t.append("\n")
t.append("/skills to enable", style=_COLOR_FAINT)
t.append("/skills to enable", style=COLOR_FAINT)
return t


Expand All @@ -184,12 +181,12 @@ def _recent_section(recent_sessions: Sequence[tuple[str, str]]) -> Text:
t.append("Recent sessions", style=f"bold {_COLOR_CYAN}")
for label, age in recent_sessions:
t.append("\n")
t.append("● ", style=_COLOR_DIM)
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)
t.append(f" ({age})", style=COLOR_DIM)
return t


Expand Down
3 changes: 1 addition & 2 deletions shellpilot/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import argparse
import os
import sys
from collections.abc import Sequence
from pathlib import Path
Expand Down Expand Up @@ -66,8 +67,6 @@ def run_cli(argv: Sequence[str] | None = None) -> int:


def _run_config(workspace: Path, action: str) -> int:
import os

from rich.console import Console

from shellpilot.cli.slash import render_config
Expand Down
4 changes: 2 additions & 2 deletions shellpilot/cli/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
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
from shellpilot.cli.status_bar import status_bar
from shellpilot.cli.theme import COLOR_ACCENT, COLOR_WARN, Glyphs

PT_STYLE = Style.from_dict(
{
Expand Down
7 changes: 6 additions & 1 deletion shellpilot/cli/manual_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ def manual_shell_loop(
continue
if line == EXIT_COMMAND:
break
exit_code = run_manual_command(line, cwd, audit)
try:
exit_code = run_manual_command(line, cwd, audit)
except KeyboardInterrupt:
# Ctrl+C aborts the running command, not the shell loop.
console.print("[yellow]Interrupted.[/yellow]")
continue
if exit_code != 0:
console.print(f"[dim]exit code {exit_code}[/dim]")
if audit is not None:
Expand Down
12 changes: 0 additions & 12 deletions shellpilot/cli/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,6 @@ def badge(level: str, *, plain: bool = False) -> Text:
return Text(f" {label} ", style=_BADGE_STYLES.get(level.lower(), "sp.badge.medium"))


def approval_head(request: ApprovalRequest, glyphs: Glyphs) -> Text:
return Text.assemble((f"{glyphs.bullet} ", ""), (request.display, ""))


def approval_info(request: ApprovalRequest, *, plain_badge: bool = False) -> Text:
info = Text(" ")
info.append_text(badge(request.risk.value, plain=plain_badge))
Expand All @@ -307,14 +303,6 @@ def approval_cwd(request: ApprovalRequest) -> Text:
return Text(f" CWD: {request.cwd}", style="sp.dim")


def approval_block(request: ApprovalRequest, glyphs: Glyphs, *, plain_badge: bool = False) -> Group:
return Group(
approval_head(request, glyphs),
approval_info(request, plain_badge=plain_badge),
approval_cwd(request),
)


def plan_step_line(index: int, step: PlanStep, glyphs: Glyphs) -> Text:
title = _sanitize_line(step.title)
if step.status == "completed":
Expand Down
30 changes: 10 additions & 20 deletions shellpilot/cli/slash.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

from __future__ import annotations

import base64
import dataclasses
import json
import os
from collections.abc import Callable
from enum import Enum
from pathlib import Path
from urllib.parse import urlsplit

from rich.console import Console
from rich.table import Table
Expand Down Expand Up @@ -84,13 +86,12 @@ class SlashAction(Enum):


def command_words() -> list[str]:
"""Completion phrases derived from HELP_ROWS: split combined rows, drop <args>."""
"""Completion phrases derived from HELP_ROWS: drop <args> placeholders."""
words: list[str] = []
for entry, _ in HELP_ROWS:
for raw in entry.split(","):
phrase = " ".join(part for part in raw.split() if not part.startswith("<"))
if phrase and phrase not in words:
words.append(phrase)
phrase = " ".join(part for part in entry.split() if not part.startswith("<"))
if phrase and phrase not in words:
words.append(phrase)
return words


Expand Down Expand Up @@ -284,8 +285,6 @@ def _locality_line(self, model: str) -> Text:
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
Expand Down Expand Up @@ -339,7 +338,6 @@ def _model(self, args: list[str]) -> None:
# 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)
Expand All @@ -356,8 +354,6 @@ def _model(self, args: list[str]) -> None:
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,
Expand Down Expand Up @@ -605,8 +601,6 @@ def _diff(self) -> None:
self._console.print(render_diff(diff, self._glyphs))

def _profile(self, args: list[str]) -> None:
import dataclasses

from shellpilot.config.model import VALID_PROFILES

settings = self._runtime.settings
Expand All @@ -630,12 +624,12 @@ def _profile(self, args: list[str]) -> None:
return
self._console.print("Usage: /profile | /profile use <supervised|balanced>")

def _logs(self, args: list[str] | None = None) -> None:
def _logs(self, args: list[str]) -> None:
audit = self._runtime.audit
if audit is None:
self._console.print("[dim]Audit logging is not active in this session.[/dim]")
return
show_all = args is not None and args and args[0] == "all"
show_all = args[:1] == ["all"]
session_filter = None if show_all else audit.session_id
events = audit.tail(15, session_id=session_filter)
if not events:
Expand Down Expand Up @@ -723,8 +717,6 @@ def _audit_memory(self, summary: str) -> None:
self._runtime.audit.write("memory_update", summary=summary)

def _memory_compact(self, stores: object) -> None:
import json

from shellpilot.llm.messages import Message
from shellpilot.memory.store import MemoryStores, Preference
from shellpilot.prompts.memory import MEMORY_COMPACT_PROMPT
Expand Down Expand Up @@ -950,7 +942,7 @@ def _attach(self, args: list[str]) -> None:

queue.stage(candidate)
# Use the already-loaded ref so a file vanishing between calls can't crash.
size = len(base64.b64decode(ref.data_b64))
size = ref.size_bytes
human_size = (
f"{size / 1024 / 1024:.1f} MB" if size >= 1024 * 1024 else f"{size / 1024:.1f} KB"
)
Expand All @@ -960,8 +952,6 @@ def _attach(self, args: list[str]) -> None:
)

def _compact(self, args: list[str]) -> None:
import dataclasses

if args and args[0] == "status":
status = self._runtime.status()
budget = status.budget
Expand Down
11 changes: 4 additions & 7 deletions shellpilot/cli/status_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,12 @@
from prompt_toolkit.formatted_text import FormattedText

from shellpilot.cli.render import _abbreviate_home, _sanitize_line
from shellpilot.cli.theme import COLOR_ACCENT, COLOR_DIM, COLOR_FAINT, COLOR_WARN

# 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
# prompt_toolkit cannot read the rich Theme, so the bar uses the shared hex
# constants from theme.py directly. COLOR_ERROR is status-bar-only (not in
# the four shared theme colors) so it remains defined here.
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.
Expand Down
20 changes: 9 additions & 11 deletions shellpilot/cli/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,21 +206,18 @@ def __init__(self, console: Console, glyphs: Glyphs, *, enabled: bool) -> None:
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:
def reveal(self, diff_text: str, *, max_rows: int) -> bool:
"""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.
Returns True when the diff exceeds ``ANIMATE_THRESHOLD`` rows (a pure
row-count predicate, independent of whether animation is enabled), so the
caller can decide whether to cap the settled panel to ``WINDOW_ROWS``.
Animation itself is a no-op when motion is off or on a non-terminal.
"""
rows, _ = _diff_rows(diff_text, self._glyphs)
if not self._enabled or len(rows) <= self.ANIMATE_THRESHOLD:
return
long_diff = len(rows) > self.ANIMATE_THRESHOLD
if not self._enabled or not long_diff:
return long_diff
total = len(rows)
ticks = max(1, math.floor(self.TOTAL_DURATION / _REFRESH_SECONDS))
chunk = max(1, math.ceil(total / ticks))
Expand All @@ -245,6 +242,7 @@ def reveal(self, diff_text: str, *, max_rows: int) -> None:
# scrollback and double-rendering under the settled panel.
live.update("", refresh=False)
live.stop()
return long_diff

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."""
Expand Down
Loading