Skip to content

v0.10.0 — Security hardening + opt-in cloud models#3

Merged
lavindeep merged 42 commits into
mainfrom
feat/v0.10.0-security-cloud
Jun 25, 2026
Merged

v0.10.0 — Security hardening + opt-in cloud models#3
lavindeep merged 42 commits into
mainfrom
feat/v0.10.0-security-cloud

Conversation

@lavindeep

@lavindeep lavindeep commented Jun 21, 2026

Copy link
Copy Markdown
Owner

v0.10.0 — Security hardening + opt-in cloud models

A combined release: a red-team-audited security hardening pass, opt-in cloud models built on the hardened base, four opt-in workflow skills, and a UI/UX overhaul.

Opt-in cloud models (off by default)

Local-first stays the default and the only full-privacy posture. Enabling a cloud/remote model is a deliberate, security-gated act:

  • [model] allow_cloud (default false). It has no env override — an ambient env var can never enable egress. It is settable only by editing config.toml or via a confirm-gated /config set (amber warning + explicit confirm, persisted to overrides.json); the per-session consent gate below is the real egress boundary regardless of how it was set.
  • Fail-closed per-session consent before any egress, on every selection path (boot picker, --model, /model use); a non-TTY session refuses with no network call.
  • Unspoofable active-cloud indicator: boot banner, a persistent header bar above the prompt, and /status locality — all derived from the live model, never from model output. Cloud detection matches the Ollama cloud tag in both name:cloud and name-cloud forms.
  • Best-effort outbound redaction for non-loopback turns (defence-in-depth, not a guarantee), plus cloud_consent_granted and model_request egress audit events (host/model/counts only — never message bodies).

Security hardening pass

  • run_command reader path now honours the workspace boundary and sensitive-read gate.
  • Terminal output sanitised for control/ANSI sequences at every sink.
  • Two-tier egress/safety config invariant: structural keys (model.options, skills.enabled) are config-file-only; egress/safety keys (tools.web, model.base_url, runtime.security_profile, model.allow_cloud) are high-stakes — settable via config.toml or a confirm-gated /config set, never via an env var. A boot notice lists any active high-stakes override.
  • Approval-path display integrity: the path shown at the approval gate is the resolved action path (via the same resolve_in_workspace), so a model can't spoof an approval with a misleading raw argument.
  • Project AGENTS.md gated behind trust-on-first-use (re-prompts on content change).
  • DNS-rebinding SSRF defence in web_fetch (resolve-and-validate every address, every redirect hop).
  • Session and audit-log files created at mode 0600.

Approvals & control

  • Reject-and-steer: approval prompts are [y]es / [e]dit / [n]o. [e] drops the proposed action without running it and feeds your instruction back to the model, which re-proposes a corrected action that re-enters the full classify→decide→approve gate. High-risk commands still require typing the literal run.

Workflow skills

Four opt-in builtins via progressive disclosure (skill_read): debugging, verification, code-review, git-workflow.

UI / UX

  • Redesigned boot banner: bounded-width sectioned layout, centered fighter-jet logo, commands + tips + workflow-skills + recent-sessions columns. Recent-session labels are sanitised (untrusted past input).
  • Streamlined one-key model picker (last flight → Enter, or any key for the full menu).
  • Approval diffs reveal with a bounded scrolling animation; changed lines render as separate full-width red removal / green addition bars; long diffs settle into a windowed view with a … (+N more) footer.
  • ! runs a one-off shell command (or ! alone enters Manual Shell); /config edit writes a commented starter config.toml when none exists (never overwrites an existing one; loads to defaults).
  • Command surface trimmed: one exit command (/exit); /prefs folded into /memory show (which now shows each preference's scope/source); the /config reset <key> alias removed.

Docs

README and DESIGN corrected so user-facing claims match shipped behavior (pytest now asks under balanced; the SHELLPILOT_* env-var list; the egress-key tiers).

Validation

  • Phase gate green: ruff + ruff format + mypy --strict + 1157 tests. CI green on Python 3.11 and 3.14.
  • Live acceptance: gemma4:e4b local no-regression (banner, picker, /status local, a turn) and gemma4:31b-cloud cloud path (consent-before-egress, indicator, /status REMOTE, a response, full audit trail); allow_cloud=false and non-TTY refusals verified fail-closed.

Design of record: docs/DESIGN.md §14 (approvals), §15 (cloud/egress consent), §17 (config), §31 (visual design), §32 (model picker), §36 (security hardening).

lavindeep added 30 commits June 20, 2026 15:29
Reader executables (cat/head/tail/grep/rg/wc/file/stat/du) classified LOW
auto-ran under the balanced profile even when a path argument resolved
outside the workspace, a parallel ungated file-read channel that bypassed
the boundary read_file/search enforce. Reuse the (renamed)
_path_arg_outside_workspace boundary check for these reader execs so an
out-of-workspace read escalates to HIGH (never AUTO). Marker-secret reads
remain HIGH via the existing _touches_secret_path check; in-workspace
reads are unchanged.
The prior test compared the full Live-streamed export_text() against a
single static Markdown print; those can never be equal because rich Live
records intermediate streaming frames (the leading partial token leaks into
the recorded output). Rewrite it to assert on the markup handed to Markdown
via a recording monkeypatch (the same pattern the ANSI-sanitization test
uses): the final flush must hand the complete, un-corrupted fenced markdown
to Markdown, and the rendered output shows the code block and bold text.
Sanitization is a no-op on control-free markdown, so this proves B1 does
not corrupt legitimate multi-line content.
The append-only JSONL audit log and session transcripts were created with
open(path, "a"), yielding 0644 files and 0755 parent dirs under the default
umask, while memory.json/overrides.json already use mkstemp (0600). These two
files hold the highest-value at-rest data (full prompts/replies/tool args and
output; every command/path/arg). Create them via os.open(..., O_WRONLY|O_CREAT
|O_APPEND, 0o600) with parent dirs 0o700, matching the json_store reference.
New files only; append semantics and redaction-before-write unchanged.
Network egress (tools.web, model.base_url) and the security posture
(runtime.security_profile) could be set via the program-managed
overrides.json and /config set, and base_url/profile via env vars,
despite the invariant that such changes must be explicit config.toml acts
(audit F7). Unify the per-key special-cases into one CONFIG_FILE_ONLY_KEYS
set enforced at both the overrides loop and validate_override (messages
byte-identical for model.options/skills.enabled), drop
SHELLPILOT_OLLAMA_BASE_URL and SHELLPILOT_PROFILE from ENV_MAP, and mark
runtime.security_profile boot-only. Also stop resolve_base_url() reading
SHELLPILOT_OLLAMA_BASE_URL so clients built without an explicit base_url
(e.g. doctor) cannot have their endpoint redirected by an ambient env
var. DESIGN 17.4/17.5 updated.
Group B sanitized streamed text and command/tool output, but plan.goal and
step.title (straight from the model's propose_plan/update_plan args) still
rendered as bare Text in plan_panel/plan_step_line — so a hostile model could
emit terminal control codes (e.g. \x1b[2J) to clear or spoof the plan and
approval UI. Route both through the existing _sanitize_line helper. No new
deps; mirrors the Group B sanitization.
_check_url validated only IP literals; a DNS name returned early WITHOUT
resolution, so a public-looking host resolving to a private/loopback/metadata
IP (DNS rebinding) bypassed the guard whose purpose is to block those (audit
F11). Now resolve every name via getaddrinfo and run each resolved address
through a single not-is_global predicate (also applied to IP literals; this
additionally catches CGNAT 100.64.0.0/10, e.g. the Alibaba/Oracle metadata
endpoint). Redirect hops already re-check, so they inherit validation. The
connection is deliberately not IP-pinned (would break TLS SNI/cert for
arbitrary HTTPS); the narrow resolve->reconnect rebind window is an accepted,
documented residual on a single-user, approval-gated fetch.
Project (workspace) AGENTS.md was injected as authoritative "## Project
instructions" with the same authority as ShellPilot's own system prompt,
default-on, every turn, no consent — so cloning/running in an untrusted repo
silently loaded attacker-authored standing instructions (audit F4). Gate the
PROJECT AGENTS.md behind trust-on-first-use: its SHA-256 digest is recorded in
.shellpilot/state.json; an unrecognized or changed file prompts the user
(default No) before it is loaded, a non-TTY session fails closed (not loaded).
The GLOBAL config-dir AGENTS.md stays trusted. workspace_state now
read-merge-writes so the trust digest and last_model coexist. DESIGN 16.2.1.
With a remote model endpoint the whole prompt egresses to a provider, yet
redaction ran only on disk sinks (F3), nothing recorded that a request was
sent or to where (F10/F12), and the web tools' query/url egress was unaudited.
Add an egress-locality signal to the runtime (non-loopback base_url; a hook is
left for Part 2's cloud-model names) and, on egressing turns only: send a
best-effort redacted COPY of the messages (never mutating history; images and
novel-format secrets still egress, disclosed) and write a body-free
model_request audit event (host/model/counts). A loopback turn is byte-
identical. The executor writes a redacted web_egress event for NETWORK tools
after all gates pass. Best-effort DiD, not a guarantee. DESIGN 15.1/22.
…gate

A cloud/remote model sends the entire prompt off-device, so this gates that
egress on explicit, fail-closed consent. Add is_cloud_model + a master
[model] allow_cloud switch (default off, config-file-only + boot-only, never
env/override/set-reachable). A model is egressing when it is a -cloud name OR
the base_url is non-loopback (shared is_loopback_url, fail-closed on
unparseable/no-host URLs, used by both the consent gate and the Group-E
redaction trigger so they cannot drift). Before ANY model load/chat, an
egressing session must clear allow_cloud and a per-session y/N (default No)
consent prompt that honestly states the whole prompt egresses possibly
unredacted; a non-TTY session is refused. Both boot and /model use are gated
(no preload/switch on refusal); the local availability gate is skipped for
cloud names. cloud_consent_granted is audited. The base system prompt drops
its false 'entirely on this machine / no network access' claim when egressing
(local prompt byte-identical; PROMPT_VERSION 4->5). Default localhost users
are completely unaffected. DESIGN 15.2/17/22.
v0.10.0 makes cloud models possible, so the README's unconditional
'nothing leaves your machine' framing is now 'local-first by default':
nothing leaves the box unless the user opts into cloud via config-file-only
[model] allow_cloud and clears a per-session consent prompt. Adds a 'Cloud
models (opt-in)' section (off by default, the consent flow, the honest
whole-prompt-egress disclosure with best-effort-not-guaranteed redaction,
the cloud_consent_granted/model_request audit events, the /model use gate)
and moves opt-in cloud from later-candidates into the v0.10.0 milestone.
…rkflow skills

Four builtin workflow skills that consume the v0.9.0 progressive-disclosure
mechanism: each has a lean ENABLED-triggered SKILL.md body (~175-185 tokens)
that routes by name, via skill_read, to deeper on-demand references/ docs (not
injected until read). Opt-in via [skills] enabled, so a default session is
byte-identical. git-workflow's risk claims match the command classifier
(reset/clean/branch-delete/force-push HIGH, plain push MEDIUM). Adds a guard
test against the fabricated-tool-name class (the search tool is search_text).
DESIGN 23.4.
Swap the boot-time banner at run_interactive for the new panel renderer
(banner.py:render_banner), passing the boot-time egressing_session so the
model name reads green for a local session and amber for a cloud/remote one.
The old render.banner had no other callers, so it and its test are deleted
as dead code (the profile it showed remains in the context line and /status).
First boot in a workspace still shows the full numbered menu; every boot
after shows a compact 'Last flight: <model>' confirm where Enter (or EOF /
Ctrl-C) flies the last model and any other key opens the full menu. Adds
the pure confirm_last_model(console, last_model) helper with the console
injected for testability. The chosen model flows unchanged into the
availability and cloud-egress consent gates.
Extract the cloud/remote egress predicate into is_egressing(model, base_url)
in config/model.py (one source of truth) and reuse it at the boot consent
gate, the runtime egress chokepoint, and /model use. Add three unspoofable
indicators derived from the LIVE egress signal: the boot banner already
colors the model name, a persistent amber 'CLOUD MODEL ACTIVE' header bar
above the prompt each REPL iteration (so mid-session /model use flips it),
and a /status 'Locality:' line (REMOTE host vs local). A default local
session is visually unchanged.
…ionals

Expand the v0.10.0 roadmap bullet to cover all shipped features: security
hardening pass (policy, terminal sanitization, DNS guard, file modes,
config-key hardening, AGENTS.md TOFU), opt-in cloud with consent gate and
active-cloud indicator, four workflow skills, boot banner, streamlined
picker, and approval diff scrolling reveal.

Remove "richer workflow skills" from the Later candidates line — they
shipped in v0.10.0.

Add the four opt-in workflow skills (debugging, verification, code-review,
git-workflow) to the Skills row in the Capabilities table.
…ational requests

The chokepoint claim that every model request passes through the
conversation.py tool loop was inaccurate: /memory compact
(SlashDispatcher._memory_compact) calls the Ollama client directly,
bypassing the model_request audit event and the outbound-redaction pass.

Scope the claim to every conversational model request and add an
accepted-residual note: per-session consent already covers the call and
the content is stored preferences only (never history/files/output); the
open gap is audit-completeness, with routing _memory_compact through the
chokepoint a planned hardening follow-up. Note /compact is unaffected
(no model call).
- Header status/date -> through v0.10.0, 2026-06-21.
- 1 Purpose / 2 Original Repo Baseline: bump trailing "through v0.8.0"
  current-state references to v0.10.0; qualify "local-only through
  Ollama" as local-first with opt-in cloud (15.2).
- 5.1 Local First: qualify "all model calls through local Ollama" as
  by-default; cloud/remote is opt-in, off by default, gated by
  [model] allow_cloud + per-session consent (15.2).
- 15 privacy preamble: qualify "No cloud model calls" to by-default,
  with consent + indicator + egress audit as the boundary.
- 23.4 workflow-skills label v0.9.x -> v0.10.0 (shipped this release).
- New section 36 Security Hardening: documents the red-team-audited
  pass at category level (no exploit mechanics) under the threat-model
  framing of machine-from-model (existing safety) vs user-data-from-
  provider (cloud egress): classifier read-path boundary, terminal
  output sanitization, config-file-only egress invariant, AGENTS.md
  TOFU, egress chokepoint + audit, web_fetch SSRF resolve-and-validate,
  0600 file-permission parity, and the cloud consent triad.

Each claim verified against the committed implementation. Section 31.4
already carries the diff-reveal description (be26e96) and is unchanged.
is_cloud_model used name.endswith("-cloud"), which matched sized cloud
variants (gemma4:31b-cloud) but missed the un-sized ':cloud' tag form
(nemotron-3-super:cloud). On a loopback Ollama endpoint is_cloud_model is
the only egress signal, so a ':cloud' model was classed local: no consent
gate, no active-cloud indicator, no model_request audit, no outbound
redaction -- a silent egress, exactly the hole v0.10.0 closes.

Match the cloud tag on the segment after the final ':' (== "cloud" or
ending "-cloud"), covering both Ollama forms and failing toward cloud.
Update DESIGN 15.1/15.2/36.5 and the docstrings to describe both tag forms.

Found during v0.10.0 live-acceptance prep against the installed cloud models.
… symmetry

Replace the command-only two-column banner with a bounded-width panel split by
a full-height vertical divider: left column centers the welcome line, jet,
model name, and a "<profile> · <local|cloud>" sub-line; right column stacks
Commands / Tips / Workflow skills / Recent sessions, separated by dim rules.
The Workflow skills section shows enabled skills or, when none, the available
builtins plus a "/skills to enable" hint; Recent sessions is omitted when empty.

Fix the live-render jet asymmetry: the old per-line justify="center" skewed
narrow rows because Rich strips trailing whitespace. The jet is now one
fixed-width (28-cell) block centered as a unit via Align.center. A regression
test pins per-row symmetry (fails against the old implementation).

Wire real data via render_banner(model, *, is_cloud, profile, skills,
recent_sessions): profile from settings.runtime.security_profile, skills from
settings.skills.enabled, recent sessions from a new SessionStore.recent()
(label = truncated first user message, falling back to the model name) mapped
to relative ages by _relative_age(). Recent list is captured before the current
session's meta is written so it never lists itself.

DESIGN §31.10 updated.
'!<cmd>' runs a single command through the existing audited manual-shell
path (raw shell=True, same as /shell); a bare '!' opens the shell loop.
The banner already advertised '! <cmd>' but no handler existed, so '!'
lines fell through to the model as prompts. This is a human-only escape
(model output never reaches the prompt reader), carrying the same trust
as /shell. Test: a '!' line routes to run_manual_command and is never
sent to the model.
lavindeep added 12 commits June 21, 2026 11:42
The Recent sessions label is a snippet of a past session's first user
message — untrusted, possibly-pasted input persisted verbatim (secret-
redacted only, not control-stripped). It was appended to the banner Text
unsanitized, so a stored ANSI/control sequence (e.g. clear-screen) could
repaint the terminal on next boot. Route the label through _sanitize_line
at the render sink (Group B). Test: an ESC in a recent-session label is
stripped before render.
…ep env blocked

Split the locked config set: CONFIG_FILE_ONLY_KEYS now holds only the two
structural keys (model.options, skills.enabled), and a new HIGH_STAKES_KEYS
holds the four egress/safety keys (tools.web, model.base_url,
runtime.security_profile, model.allow_cloud).

The four egress keys are now settable at runtime via /config set, gated by an
amber warning + explicit confirm (per-key risk, boot-only "next session" note,
"persists until /config unset" note; runtime.security_profile also points at
/profile use for a session-only change). A decline saves nothing. They flow
through the overrides layer normally (source "set"); validate_override no
longer rejects them. The two structural keys still reject in both chokepoints.

The env invariant is preserved and load-bearing: all four keys stay absent
from ENV_MAP — no ambient env var can enable egress or downgrade the profile.
The per-session cloud-consent gate is unchanged and remains the egress barrier.

A boot notice (high_stakes_override_notice, a pure testable helper) lists any
active high-stakes override on every launch, so a set-and-forget egress
override can't quietly stay on.

DESIGN updated in-commit: rewrote the two-tier setter invariant (§17.3, §36.3),
the /config set high-stakes doc (§17.3), the allow_cloud bullet (§15.2), the
consent-triad control 1 (§36.8), §5.1, the env-section and metadata-probe
notes. The env-absence claim stays true everywhere.
The relax-config change warned that every high-stakes /config set "takes
effect next session," but runtime.security_profile is read per turn
(conversation.py) and update_settings propagates it — so the safety downgrade
applies immediately, the same live effect as /profile use. The warning (and the
boot-only note) falsely deferred a live safety change to next session.

Remove runtime.security_profile from BOOT_ONLY_KEYS (it was misplaced — the set
means "construction-time only," which it is not), key the /config set when-note
off BOOT_ONLY_KEYS membership ("this session (next turn)" vs "next session"),
reword the /profile hint to "unsaved, session-only," and stop the boot-only note
double-printing for high-stakes keys. The three egress keys (tools.web,
model.base_url, model.allow_cloud) stay boot-only and the env-absence invariant
is unchanged for all four.

DESIGN updated in-commit (§17.3 setter notes, §36.3, the high-stakes key list).
Found by the post-relax security review.
One exit command instead of two — /quit is no longer recognized (falls through
to the unknown-command hint pointing at /help). Updated HELP_ROWS, the command
table in README/DESIGN, and the completion-word + dispatch tests.

Also hoist the three function-local `from shellpilot.config.model import ...`
re-imports (is_cloud_model/is_egressing/is_tested_model/TESTED_FAMILIES) to a
single module-level import — config.model is already in slash.py's import graph
via runtime.conversation, so there's no cycle. Net deletion, and it clears the
duplicate-import that Pyright flagged as a Final reassignment.
… alias

/prefs had converged with /memory: /prefs edit only printed the store paths
(/memory show already prints them) and /prefs show was a preferences-only slice
of /memory show (which already lists prefs and facts with their ids). Retire
/prefs and fold its one extra detail — the per-preference (scope, source) tag —
into /memory show via a keyword-only render(meta=True). The injected memory
prompt format is unchanged (meta defaults False), so the model-facing prompt is
byte-identical.

Also demote /config reset <key>: it merely aliased /config unset <key>.
/config reset <key> now falls to the usage hint; /config unset <key> is the one
way to remove a key, and /config reset (no key) still clears all.

HELP_ROWS, README, and the DESIGN command tables updated to match. Tests updated
red-first (render meta display-vs-injected, /memory show carries the tag +
/prefs gone, /config reset <key> no longer unsets).
The hardening changed behavior but README/DESIGN still described the old model:
- pytest is now MEDIUM/ASK (Group A dropped it from LOW_EXECUTABLES + removed the
  -m pytest carve-out), but README L135 + a transcript + DESIGN §11.1/§14.3 still
  showed it auto-running under balanced. Fixed to genuinely-LOW examples
  (ls, git status, in-workspace cat) and a MEDIUM approval prompt in the transcript.
- SHELLPILOT_* env vars are now only model/color/glyphs (Group C dropped the
  Ollama-URL + profile env overrides); README L207 list corrected.
- tools.web / model.base_url / runtime.security_profile / model.allow_cloud are
  HIGH_STAKES — settable via config.toml OR a confirm-gated /config set (persisting
  through overrides.json), never env. README L234/L246/L262 said they were
  config-file-only and not /config-set-able; corrected, and the two tiers
  (config-file-only {model.options, skills.enabled} vs high-stakes) now described
  accurately. README now agrees with the in-commit DESIGN §17.3/§36 updates.

Docs-only; verified against command_policy.py / loader.py / slash.py.
A changed line emitted a separate removal row and addition row (correct), but
_diff_row colored only "{marker} {content}" — so a short removal's red bar
stopped at its text while the longer addition's green bar ran further, and with
both sharing a gutter number the pair didn't read as two distinct unified-diff
lines (violating DESIGN §31.4's "full-line backgrounds").

Compute pad_width (widest changed-line content via rich.cells.cell_len) and
right-pad each backgrounded row's content INSIDE its style so removals and
additions render as uniform full-width bars: old text on its own red row, new
text on the next green row. Fix lives in the shared _diff_rows, so render_diff
and the streaming DiffReveal stay consistent. Headers (---/+++), context,
"\ No newline", and the truncation markers stay un-padded; the row-counting cap
is unaffected.

TDD red-first (test_render_diff_changed_lines_are_separate_full_width_bars,
fails 17==26 before, passes after). DESIGN §31.4 clarified in-commit.
Previously /config edit printed a path to a config.toml that doesn't exist by
default (the config dir only holds overrides.json/memory/audit), which is
confusing and useless. Now, when the user config is absent, /config edit writes
a commented starter template at that path (then prints it + boot-only vs
/config-set guidance); when it exists, it only prints the path — never rewrites.

Safety: the create path is doubly guarded — an exists() check AND os.O_EXCL — so
an existing config.toml is never touched (the project never rewrites config.toml).
The template is entirely commented out, so it loads to pure defaults with zero
warnings (config.toml errors are fatal, so a valid-empty template is required);
every commented example value is valid if uncommented. Written 0600. Comments
honestly describe the egress/safety tiers (config-file-only {model.options,
skills.enabled} vs high-stakes {tools.web, model.base_url, runtime.security_profile,
model.allow_cloud} — config.toml or confirm-gated /config set, never env).

TDD red-first (creates-starter, 0600 perms, does-not-overwrite). DESIGN §17.2
documents the behavior.
…del arg

The action path was always bounded by resolve_in_workspace, but the path shown
to the user (tool-call line, approval head + audit, write/patch diff title) was
the RAW model argument — so a model could make the display read as one file
while a different in-workspace file is actually touched, spoofing the approval.

Add workspace_display(workspace, raw_path) in tools/base.py next to
resolve_in_workspace; it resolves via the SAME resolver and returns the
workspace-relative path (or "<outside workspace>" on a boundary escape). Route
the three approval-time displays through it (executor _display_for/_display_value,
terminal show_tool_call, patch.unified_diff title via its callers) so display ==
action by construction. The boundary resolution itself is byte-for-byte
unchanged — this is display-only. Escapes render the honest marker, never a
fabricated path; all sinks remain Group-B sanitized.

TDD red-first (spoof arg -> resolved display; escape -> marker), incl. a
subdirectory-target preview test so resolved-relative != basename genuinely
guards the header. DESIGN §14.5 + new §36.9.
…provals

Approval prompts are now [y]es / [e]dit / [n]o. Choosing [e] asks "Tell the
model what to do instead:", drops the proposed action WITHOUT running it, and
feeds the guidance back as the tool result so the model proposes a corrected
action — which re-enters the normal classify->decide->approve gate like any tool
call. This is reject-and-steer (akin to /plan revise), not inline-edit-and-run:
the user never edits a command that then executes under the badge it was approved
under.

Seam: ApprovalAsker now returns ApprovalReply(approved, steer_text) instead of
bool (new frozen type + APPROVE/DECLINE singletons in policy/approvals.py). The
sole consumer (executor) checks reply.approved explicitly — no object-truthiness
path, so nothing runs on decline or steer (proven red-first: a steered rm -rf
deletes the decoy against the old bool code, survives now). High-risk commands
still require typing the literal "run" (a plain y is rejected; [e] steers without
running). Empty guidance after [e] = plain decline. Steered approvals audit as
decision="steered".

TDD red-first (8 tests across executor/terminal/conversation). DESIGN §14.6 +
§31.5. Verified with a full truthiness-trap sweep.
…ntext %

Add a one-line status bar pinned at the input via prompt_toolkit's
bottom_toolbar: `~/Projects · gemma4:e4b · balanced · ● local    12% ctx`.
Model + locality go green when local, amber (`☁ CLOUD`, bold, with an amber
chevron + separator wash) when egressing; context % is color-coded
green<50 / amber 50-79 / red>=80.

This folds the active-cloud indicator into one always-on place: the separate
amber "CLOUD MODEL ACTIVE" header bar is removed, and context utilization moves
off the per-response line (turn_finished is now a no-op; the runtime still emits
TurnStats for the protocol) into the bar. The indicator stays unspoofable and
harness-rendered — is_cloud is is_egressing(live model, base_url) recomputed each
prompt; dir/model/profile/ctx come from runtime.status(), never model output; the
user-controlled workspace path is sanitized before render.

New pure builder shellpilot/cli/status_bar.py (mirrors banner.py) with
ctx_percent() reusing the runtime's exact formula. Non-TTY PlainInput unchanged
(and a non-TTY session can't egress — the consent gate refuses it).

TDD red-first (test_status_bar.py: local-no-amber, cloud-amber+CLOUD, ctx
thresholds, separators, home-abbrev, control-char sanitization); removed the
turn_stats render test for the deleted per-turn line. DESIGN §31.11 (new) +
§31.2/§31.8/§15.2/§36.8.

Queued input / type-ahead (prototype State C) is deferred to its own pass.
Correct README/DESIGN drift from the v0.10.0 work:
- README session transcript: panel-banner note, [y]es/[e]dit/[n]o approval
  prompt, drop the removed per-turn stats line, status bar shows locality +
  ctx%; document the ! one-shot shell escape; qualify project vs global
  AGENTS.md trust; refresh the v0.10.0 changelog + add a v0.10.1 roadmap note.
- DESIGN: §20.1 Commands (+ /config set/unset/reset rows), §21 ! escape note,
  §33.4 rewrite the DNS-rebind limitation to the real residual (resolve-and-
  validate shipped; no IP pin — §36.6), §36.3 note SHELLPILOT_PROFILE removal.
- Normalize a few internal comment markers and UI-analogy phrasing.

Docs/comments only; phase gate green.
@lavindeep lavindeep force-pushed the feat/v0.10.0-security-cloud branch from 5fe27df to d16bf70 Compare June 25, 2026 18:47
@lavindeep lavindeep merged commit 4c1a68a into main Jun 25, 2026
2 checks passed
@lavindeep lavindeep deleted the feat/v0.10.0-security-cloud branch June 27, 2026 18:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant