diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e941ec6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + windows: + name: Build + Lint + Test (Windows) + runs-on: windows-latest + timeout-minutes: 25 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Setup Rust (stable) + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache Rust target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Install Tauri build prerequisites + # tauri-build needs the WebView2 runtime headers on Windows. The runner + # has them pre-installed; this step is a no-op placeholder for clarity. + run: echo "WebView2 runtime is preinstalled on windows-latest" + shell: pwsh + + - name: npm ci + run: npm ci + + - name: TypeScript + Vite build + run: npm run build + + - name: cargo fmt --check + working-directory: src-tauri + run: cargo fmt --check + + - name: cargo clippy + working-directory: src-tauri + run: cargo clippy --all-targets -- -D warnings + + - name: cargo test + working-directory: src-tauri + run: cargo test --all-targets diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f2e3d..9a1b366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [v0.38.0](https://github.com/mandarwagh9/embedist/releases/tag/v0.38.0) — 2026-05-28 + +### Security +- `run_shell` now enforces a command allowlist on top of the existing metacharacter denylist. Only embedded-development tooling (`pio`, `git`, `npm`, `cargo`, `python`, `make`, `cmake`, plus a handful of read-only utilities) can be invoked through the agent; everything else is rejected with a clear error. Every invocation is logged via `log::info!` so the user can audit what the AI ran. + +### Fixed +- Plan-phase auto-detection (`AIChatPanel.tsx`) no longer triggers on bare substrings like `approve` or a stray `?` anywhere in the response. Phase transitions now prefer explicit `[[phase: ready]]` / `[[phase: clarify]]` markers emitted by the plan-mode system prompt, with a tightened phrase fallback anchored to the last 400 chars (e.g. "plan ready for approval", "awaiting clarification"). Eliminates a class of false-positive mode transitions. + +### Added +- **Test suite.** Rust commands now ship with `#[cfg(test)]` coverage: 14 unit tests in `filesystem.rs` exercise the shell allowlist (allowed/denied/empty/metachar paths), program-name extraction (with path prefixes and Windows `.exe`/`.cmd` suffixes), leaf-name validation against directory-traversal, lexical path normalization, and `validate_path` containment against `..` escapes. Run with `cargo test --manifest-path src-tauri/Cargo.toml`. +- **CI.** GitHub Actions workflow (`.github/workflows/ci.yml`) runs `npm run build`, `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, and `cargo test --all-targets` on every push and pull request, gated to `windows-latest` (the only supported target). +- **Persisted settings schema versioning.** `settingsStore` now declares `version: 1` and a `migrate` callback in its zustand `persist` config, providing a hook for non-breaking schema evolution in future releases. + +### Documentation +- TODO-fix.md reconciled against current source. Of the 9 items previously listed as open, 6 had already been resolved in shipped releases (`defaultImplementationMode` toggle, `PlanPhaseIndicator`, token-usage display, `SerialConfig`, bottom panel resize, keyboard-shortcuts modal). The doc now reflects reality. +- `docs/superpowers/specs/2026-05-28-production-grade-design.md` captures the scope, goals, and verification criteria for this release. + +--- + ## [v0.37.0](https://github.com/mandarwagh9/embedist/releases/tag/v0.37.0) — 2026-04-19 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e7e553e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repo layout note + +The app root is this directory (`embedist/embedist`). The parent folder `embedist/` is only a container — do not run npm/cargo from there. A separate Docusaurus package lives in `docs/` with its own `package.json`; only run its scripts when editing docs. + +`AGENTS.md` in this directory contains overlapping developer notes; keep both in sync when behavior changes. + +## Commands + +Frontend scripts (`package.json`): +- `npm run dev` — Vite dev server (port 1420, required by `tauri.conf.json`) +- `npm run build` — `tsc && vite build` (runs TypeScript strict-mode check) +- `npm run tauri dev` — run the full desktop app in development +- `npm run tauri build` — produce release artifacts + +There is **no** `lint` or `test` npm script. No automated test suite exists (`*.test.*`, `*.spec.*`, `src-tauri/tests` are all absent). Validation = `npm run build` + `cargo clippy --manifest-path src-tauri/Cargo.toml` + manual app checks. + +Release artifacts from `npm run tauri build`: +- Portable: `src-tauri/target/release/embedist.exe` +- Installer: `src-tauri/target/release/bundle/nsis/Embedist__x64-setup.exe` (NSIS only — set by `tauri.conf.json` `bundle.targets`) + +## Release version sync + +When bumping the version, update **all three** files — they're independent and drift silently: +- `package.json` +- `src-tauri/Cargo.toml` +- `src-tauri/tauri.conf.json` + +## Architecture + +Tauri 2 desktop app (Windows-only target). React 18 + TypeScript (strict) frontend talks to a Rust backend via Tauri `invoke` commands. + +**Frontend entry:** `src/main.tsx` → `src/App.tsx`. State lives in Zustand stores under `src/stores/` (`aiStore`, `fileStore`, `settingsStore`, `uiStore`) with `localStorage` persistence. Business logic is in hooks (`src/hooks/use*.ts`). + +**Backend entry:** `src-tauri/src/main.rs` → `src-tauri/src/lib.rs`. All Tauri commands are registered in the `invoke_handler!` macro in `lib.rs` and implemented in `src-tauri/src/commands/{ai,filesystem,platformio,pty,serial}.rs`. Shared async state (`SerialState`, `AIState`, `BuildState`, `PtyState`, `WatchState`) is attached via `.manage()` in `lib.rs` — add new state there. + +**AI modes:** chat / plan / agent / debug. Mode-specific system prompts are Markdown files at `src/lib/prompts/modes/*.md`, imported as `?raw` strings via `src/lib/prompts/index.ts`. `PROMPTS` in that file also defines each mode's RAG context categories and empty-state copy. + +**Tool calling:** Agent-mode tool execution runs client-side — `src/hooks/useAgent.ts` drives the loop (up to `MAX_ITERATIONS = 50`), dispatching to tools in `src/lib/agent-tools.ts`. Path safety checks live in `useAgent.ts` (`isPathSafe`, `PROJECT_SCOPED_TOOLS`). The Rust backend (`src-tauri/src/commands/ai.rs`) implements tool-enabled chat for OpenAI / Anthropic / DeepSeek / custom OpenAI-compatible endpoints. **Ollama and Google providers are text-only** in the backend — don't assume tool calling works there. + +**PlatformIO integration:** `src-tauri/src/commands/platformio.rs` shells out to `pio`. Build output streams via Tauri events; errors are parsed into a Problems panel. Build cancellation uses the shared `BuildState`. + +**CSP:** `tauri.conf.json` restricts `connect-src` to the specific AI provider hosts + `localhost:11434` (Ollama). Adding a new provider host means updating that CSP. + +## Rust gotchas (from prior pain) + +- For cloneable async shared state, use `Arc>` (see `BuildState` in `src-tauri/src/commands/platformio.rs`). +- Do **not** use `Option::is_none_or(...)` — prefer `is_some_and(...)` or explicit match for stable Rust. +- `run_shell` in `src-tauri/src/commands/filesystem.rs` rejects shell metacharacters; it won't run chained/piped expressions. diff --git a/TODO-fix.md b/TODO-fix.md index 58db09a..147a44f 100644 --- a/TODO-fix.md +++ b/TODO-fix.md @@ -1,6 +1,6 @@ # Embedist — TODO Fix List -Generated from comprehensive codebase analysis. Last updated: v0.34.0. +Generated from comprehensive codebase analysis. Last reconciled against source: **v0.38.0** (2026-05-28). ## Legend - **BROKEN**: Feature doesn't work at all @@ -84,26 +84,17 @@ Effort: Trivial (1 line) | Low (<1hr) | Medium (1-4hr) | High (4+hr) ### 3.1 Agent Mode with Non-Tool Providers — FIXED - **Fixed in v0.33.0**: Warning banner shown in Agent mode when using DeepSeek/Ollama/Google -### 3.2 `defaultImplementationMode` Toggle — MISSING -- `settingsStore.ts:45` has `defaultImplementationMode: 'agent'` but no UI control -- **Fix**: Add "Default Mode After Plan Approval" toggle in `AISettings.tsx` with Chat/Agent options -- **Effort**: Low -- **Files**: `src/stores/settingsStore.ts`, `src/components/Settings/sections/AISettings.tsx` +### 3.2 `defaultImplementationMode` Toggle — FIXED +- Verified 2026-05-28: `settingsStore.ts:57` defines the field and setter; `AISettings.tsx:349-357` exposes the "After Plan Approval" Chat/Agent control. ### 3.3 AI Model Parameters — FIXED - **Fixed in v0.33.0**: Temperature, top_p, max_tokens controls added to AISettings, passed to Rust -### 3.4 `PlanPhaseIndicator` Stub — STUB -- `src/components/AI/PlanPanel/PlanPhaseIndicator.tsx` returns `null` -- **Fix**: Implement phase progress indicator (5 steps: explore→design→review→clarify→ready, current highlighted) -- **Effort**: Low -- **Files**: `src/components/AI/PlanPanel/PlanPhaseIndicator.tsx` +### 3.4 `PlanPhaseIndicator` Stub — FIXED +- Verified 2026-05-28: `PlanPhaseIndicator.tsx` renders the 5-phase indicator (explore→design→review→clarify→ready) with per-phase styling. -### 3.5 Token Usage Display — MISSING -- Backend returns `TokenUsage` but frontend ignores it -- **Fix**: Display token count in StatusBar or `MessageBubble.tsx`. Add "Show Tokens" toggle in AISettings. -- **Effort**: Low -- **Files**: `src/components/AI/MessageBubble.tsx`, `src/stores/aiStore.ts` +### 3.5 Token Usage Display — FIXED +- Verified 2026-05-28: `aiStore.ts:21-25` carries `usage` on each `AIMessage`; `MessageBubble.tsx:126-130` renders `total_tokens` when present. ### 3.6 Provider Model List Mismatch — FIXED - **Fixed in v0.33.0**: Model lists consolidated to shared constants @@ -111,11 +102,8 @@ Effort: Trivial (1 line) | Low (<1hr) | Medium (1-4hr) | High (4+hr) ### 3.7 API Request Timeout — FIXED - **Fixed in v0.11.9**: 60s timeout added to `reqwest::Client` -### 3.8 Plan Phase Auto-Detection Fragile — FRAGILE -- Phase transitions use content substring matching (`'approve'`, `'?'`) — unreliable -- **Fix**: Use explicit phase markers in AI response format, or rely on explicit user/AI action buttons -- **Effort**: Medium -- **Files**: `src/components/AI/AIChatPanel.tsx` +### 3.8 Plan Phase Auto-Detection Fragile — FIXED (v0.38.0) +- Replaced bare-substring matching with explicit `[[phase: ready]]` / `[[phase: clarify]]` markers (emitted by the plan-mode prompt) plus a tightened phrase fallback anchored to the last 400 chars. `detectPlanPhase()` is exported from `AIChatPanel.tsx` for future unit testability. ### 3.9 Plan "Discard" — FIXED - **Fixed in v0.33.0**: Discard now clears plan-related system messages from store @@ -158,11 +146,8 @@ Effort: Trivial (1 line) | Low (<1hr) | Medium (1-4hr) | High (4+hr) ## 5. SERIAL -### 5.1 `SerialConfig` Dead Code — RUST WARNING -- `serial.rs` has `SerialConfig` struct generating `dead_code` warning -- **Fix**: Use it (pass config from frontend) or remove it -- **Effort**: Trivial -- **Files**: `src-tauri/src/commands/serial.rs` +### 5.1 `SerialConfig` Dead Code — FIXED +- Verified 2026-05-28: `serial.rs` no longer contains a `SerialConfig` struct; clippy passes clean with `-D warnings`. ### 5.2 Serial Code Duplication — FIXED - **Fixed in v0.33.0**: Consolidated into `useSerial.ts`, `SerialMonitor.tsx` is a thin wrapper @@ -198,11 +183,8 @@ Effort: Trivial (1 line) | Low (<1hr) | Medium (1-4hr) | High (4+hr) ### 6.5 Sidebar Width Persistence — FIXED - **Fixed in v0.33.0**: `sidebarWidth` persisted in `uiStore` -### 6.6 Bottom Panel Resize — PARTIAL -- `uiStore` has `bottomPanelHeight` but no drag handle exists -- **Fix**: Add drag handle at top of `BottomPanel.tsx` -- **Effort**: Low -- **Files**: `src/components/Layout/BottomPanel.tsx`, `src/stores/uiStore.ts` +### 6.6 Bottom Panel Resize — FIXED +- Verified 2026-05-28: `BottomPanel.tsx:76-80` renders a `bottom-panel-resize-handle` with `onMouseDown` wiring to the resize handler (`BottomPanel.tsx:22-54`). ### 6.7 App Version Mismatch — FIXED - **Fixed in v0.33.0**: Version synced across `package.json`, `Cargo.toml`, `tauri.conf.json` @@ -210,11 +192,8 @@ Effort: Trivial (1 line) | Low (<1hr) | Medium (1-4hr) | High (4+hr) ### 6.8 Devtools — FIXED - **Fixed in v0.12.0**: Devtools enabled in `tauri.conf.json` -### 6.9 Keyboard Shortcut Help Modal — STUB -- MenuBar has "Keyboard Shortcuts" but it's a stub -- **Fix**: Create `KeyboardShortcutsModal.tsx` with all shortcuts grouped by category -- **Effort**: Low -- **Files**: `src/components/Layout/MenuBar.tsx`, new component +### 6.9 Keyboard Shortcut Help Modal — FIXED +- Verified 2026-05-28: `MenuBar.tsx:264` opens the modal; `MenuBar.tsx:458-507` renders the full shortcut listing grouped by File / Tabs / AI Modes / View / Build. ### 6.10 Unsaved Changes in Title Bar — FIXED - **Fixed in v0.11.9**: TitleBar shows `•` when any tab is modified @@ -232,11 +211,8 @@ Effort: Trivial (1 line) | Low (<1hr) | Medium (1-4hr) | High (4+hr) ### 7.2 `chat_custom` Message Format — FIXED - **Fixed in v0.34.0**: Now uses standard OpenAI `tool_calls` format in assistant messages -### 7.3 `run_shell` Security — SECURITY -- `run_shell` in `filesystem.rs` executes arbitrary shell commands -- **Fix**: Review all callers (agent-tools.ts). Add confirmation dialog for destructive shell commands. Validate input strictly. -- **Effort**: Medium -- **Files**: `src-tauri/src/commands/filesystem.rs`, `src/lib/agent-tools.ts` +### 7.3 `run_shell` Security — FIXED (v0.38.0) +- Added `SHELL_ALLOWLIST` (`pio`, `git`, `npm`/`npx`/`pnpm`/`yarn`, `cargo`/`rustc`/`rustup`, `node`, `python`/`python3`/`py`, `make`/`cmake`/`ninja`, plus read-only utilities). `validate_shell_command()` enforces the allowlist on top of the existing metachar denylist, normalizes the program name (strips path prefix and Windows `.exe`/`.cmd`), and logs every invocation. Covered by 8 dedicated unit tests in `filesystem.rs`. Tool-permission prompt in `agent-tools.ts` is unchanged — the user can still gate `run_shell` per-call. ### 7.4 `save_plan_file` — FIXED - **Fixed in v0.33.0**: Purpose clarified — saves plan files to disk for persistence across sessions @@ -257,25 +233,14 @@ Effort: Trivial (1 line) | Low (<1hr) | Medium (1-4hr) | High (4+hr) ### 8.4 Cargo Clippy Warnings — FIXED - **Fixed in v0.34.0**: All clippy warnings resolved -### 8.5 Settings Store Schema Migration — ONGOING -- Adding fields to `settingsStore` changes persisted schema -- **Fix**: Ensure defaults in initial state. Consider versioned migrations for major changes. -- **Effort**: Ongoing +### 8.5 Settings Store Schema Migration — IN PLACE (v0.38.0) +- `settingsStore` now declares `version: 1` and a `migrate` callback in its zustand `persist` config. Baseline is a pass-through; future schema bumps should branch on `_fromVersion` to reshape the persisted state. --- ## QUICK WINS SUMMARY -| # | Issue | Category | Priority | Effort | -|---|-------|----------|----------|--------| -| 1 | `defaultImplementationMode` toggle | AI | P0 | Low | -| 2 | `PlanPhaseIndicator` implement | AI | P0 | Low | -| 3 | Token usage display | AI | P1 | Low | -| 4 | `SerialConfig` dead code | Rust | P1 | Trivial | -| 5 | DeepSeek tool calling support | AI | P1 | Low | -| 6 | Keyboard shortcut help modal | UI/UX | P1 | Low | -| 7 | Bottom panel resize drag handle | UI/UX | P2 | Low | -| 8 | `run_shell` security hardening | Rust | P2 | Medium | +All previously-listed quick wins have shipped. See per-section status above for verification details. --- @@ -285,13 +250,13 @@ Effort: Trivial (1 line) | Low (<1hr) | Medium (1-4hr) | High (4+hr) |----------|------|-------| | Editor | 0 | 8 | | File Explorer | 0 | 10 | -| AI / Agent | 4 | 8 | +| AI / Agent | 0 | 12 | | Build / PlatformIO | 0 | 6 | -| Serial | 1 | 5 | -| UI / UX | 2 | 9 | -| Rust Backend | 1 | 3 | -| Architecture / Debt | 1 | 4 | -| **Total** | **9** | **53** | +| Serial | 0 | 6 | +| UI / UX | 0 | 11 | +| Rust Backend | 0 | 4 | +| Architecture / Debt | 0 | 5 | +| **Total** | **0** | **62** | --- diff --git a/docs/superpowers/specs/2026-05-28-production-grade-design.md b/docs/superpowers/specs/2026-05-28-production-grade-design.md new file mode 100644 index 0000000..947073e --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-production-grade-design.md @@ -0,0 +1,120 @@ +# Production-Grade Embedist — Design Spec + +**Date:** 2026-05-28 +**Scope:** Define what "production grade" means for Embedist v0.37.0 → v0.38.0 and the work required to get there. +**Authored by:** Claude Code (autonomous, per `/goal` directive — no interactive brainstorm). + +## Problem + +Embedist ships as a single-developer Tauri 2 desktop app. As of v0.37.0 it has: + +- **No test suite** (`*.test.*`, `*.spec.*`, `src-tauri/tests` all absent). +- **No CI** (no `.github/workflows/`); every release relies on local `npm run build` + `cargo clippy`. +- **A stale TODO list** — `TODO-fix.md` claims 9 open items, but several have matching fix commits in `git log` (e.g. 3.5 token usage, 3.10 DeepSeek tool calling) suggesting drift. +- **A documented security concern** — `run_shell` (TODO 7.3) executes arbitrary shell commands subject only to a metacharacter denylist; no allowlist, no audit log. +- **Three small UX stubs** — `PlanPhaseIndicator` returns null, the keyboard-shortcuts modal is a stub, the bottom panel has no drag handle. + +Without tests and CI, regressions in any of the AI provider integrations or the Rust security surface land silently. Without TODO reconciliation, the project's published quality bar is inaccurate. + +## Goals + +1. **Truthful TODO list.** Every "open" item is genuinely open against current code. +2. **Closed quick wins.** Every Low/Trivial open item is fixed. +3. **Security-reviewed shell.** `run_shell` is either restricted to a small allowlist of safe operations, or its denylist is upgraded with rationale, audit logging, and tests. +4. **Minimum viable test suite.** Rust `cargo test` runs at least one test for every security-sensitive function (path validation, name validation, shell-arg sanitization). Target: 20+ tests. +5. **CI gate.** Push and PR trigger a Windows GitHub Actions job that runs `tsc && vite build`, `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test`. Red CI blocks merge. +6. **Version + changelog.** Bump to v0.38.0 with a single CHANGELOG entry summarizing the production-grade work. + +## Non-Goals + +- **Cross-platform support.** Embedist is Windows-only by design (NSIS bundle, `winreg` dep, PTY uses ConPTY). CI is Windows-only. +- **E2E UI tests.** Tauri WebView E2E is high-cost and out of scope. UI is verified manually + via TypeScript strict mode. +- **Refactoring large modules.** `ai.rs` and `useAgent.ts` are large but functional. No structural refactor. +- **Adding features.** No new providers, no new editor capabilities, no new boards. + +## Scope (concrete work units) + +### A. TODO reconciliation +Read each "open" item in `TODO-fix.md` against current source. For each: +- If code already implements it: mark **FIXED**, cite the commit/file:line. +- If still open: keep open, refine the fix description. + +### B. Close genuinely-open items +Implement everything Low/Trivial that survives reconciliation. Anticipated, before reconciliation: + +| # | Item | Effort | Files | +|---|---|---|---| +| 3.2 | `defaultImplementationMode` toggle UI | Low | `settingsStore.ts`, `AISettings.tsx` | +| 3.4 | `PlanPhaseIndicator` implementation | Low | `PlanPhaseIndicator.tsx` | +| 3.5 | Token usage display | Low | `MessageBubble.tsx`, `aiStore.ts` (verify against current code first — may already be done) | +| 3.8 | Plan phase auto-detection | Medium | `AIChatPanel.tsx` — replace substring matching with explicit markers | +| 5.1 | `SerialConfig` dead code | Trivial | `serial.rs` — wire or delete | +| 6.6 | Bottom panel resize handle | Low | `BottomPanel.tsx` | +| 6.9 | Keyboard shortcuts help modal | Low | New `KeyboardShortcutsModal.tsx` + `MenuBar.tsx` | + +### C. `run_shell` security +- Audit `src-tauri/src/commands/filesystem.rs::run_shell` and every call site in `src/lib/agent-tools.ts`. +- Apply defense-in-depth: keep the existing metachar denylist **and** add a command allowlist (`pio`, `git`, `npm`, `cargo`, plus a small set the agent provably needs). +- Log every invocation via `log::info!` so the user can audit what the AI ran. +- Add `#[cfg(test)]` cases for the validator: blocks `;`, `&&`, `|`, `>`, `` ` ``, `$()`, and rejects non-allowlisted commands. + +### D. Rust test scaffolding +- Per-module `#[cfg(test)] mod tests` blocks. No new test-only binary. +- Cover at minimum: + - `filesystem.rs`: `is_path_safe`/equivalent, name validation, shell-arg validation. + - `serial.rs`: baud-rate parsing, line-ending handling. + - `platformio.rs`: `parseAnsiColor`-equivalent if any, board parsing from `platformio.ini`. + - `ai.rs`: provider URL builders, tool-call message-format helpers. +- Run with `cargo test --manifest-path src-tauri/Cargo.toml`. + +### E. CI +`.github/workflows/ci.yml`: +```yaml +on: [push, pull_request] +jobs: + windows: + runs-on: windows-latest + steps: + - checkout + - setup-node 24 + - setup rust stable + - npm ci + - npm run build # tsc + vite + - cargo fmt --check (manifest=src-tauri/Cargo.toml) + - cargo clippy -- -D warnings + - cargo test +``` +No release/publish job — keep tag-based release manual (current process). + +### F. Release +- Bump `package.json`, `Cargo.toml`, `tauri.conf.json` to `0.38.0`. +- Single CHANGELOG entry under `## v0.38.0 — Production-grade hardening`. +- Do NOT publish a GitHub release; leave that to the human. + +## Architecture impact + +Minimal. All changes are additive or scoped to single files. No new modules at the top level. The only new top-level artifact is `.github/workflows/ci.yml`. + +The most invasive change is the `run_shell` allowlist — it may reject calls the agent previously made. Mitigation: log the rejection clearly and surface to the user in the agent activity log, not silent failure. + +## Verification + +After all work is done: +1. `npm run build` exits 0. +2. `cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings` exits 0. +3. `cargo test --manifest-path src-tauri/Cargo.toml` exits 0 with >= 20 tests passing. +4. `npm run tauri dev` launches; manually verify: open a folder, open a file in Monaco, see token-usage display, open keyboard-shortcuts modal, drag bottom panel. +5. `TODO-fix.md` "open" count matches what is actually open (likely 0 after this work, or only "ONGOING" items like 8.5). + +## Risks + +- **`run_shell` allowlist breaks agent flows.** Mitigation: derive the allowlist from existing call sites in `agent-tools.ts`, not from a guess. +- **TODO 3.8 phase-marker change requires AI prompt updates.** Mitigation: keep substring fallback for one release; add explicit markers as the preferred path. +- **CI on `windows-latest` is slow (~10-15 min).** Acceptable for a Windows-only project — cross-platform CI would be the wrong trade-off. + +## Out of scope follow-ups (not blocking this milestone) + +- E2E tests via Playwright + Tauri driver. +- Code signing for Windows (removes SmartScreen warning). +- Linux/macOS support. +- Telemetry / crash reporting. diff --git a/package.json b/package.json index 3204ebc..b348d81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "embedist", - "version": "0.37.0", + "version": "0.38.0", "description": "AI-native embedded development environment", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b6f4bc0..7a449ca 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -813,7 +813,7 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" [[package]] name = "embedist" -version = "0.37.0" +version = "0.38.0" dependencies = [ "dirs", "env_logger", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a109bc7..df57f1b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "embedist" -version = "0.37.0" +version = "0.38.0" description = "AI-native embedded development environment" authors = ["Embedist Team"] edition = "2021" diff --git a/src-tauri/src/commands/filesystem.rs b/src-tauri/src/commands/filesystem.rs index e3dbb2a..29dc1e3 100644 --- a/src-tauri/src/commands/filesystem.rs +++ b/src-tauri/src/commands/filesystem.rs @@ -570,11 +570,77 @@ pub fn stop_watch(app: tauri::AppHandle) -> Result<(), String> { Ok(()) } -#[command] -pub async fn run_shell(command: String, cwd: Option, root: Option) -> Result { - if command.contains(&['&', '|', ';', '\n', '\r', '$', '`', '(', ')', '{', '}', '<', '>', '!', '~', '*', '?', '[', ']'][..]) { +// Commands the agent is permitted to invoke through `run_shell`. The list is +// intentionally narrow: tools the embedded-development workflow legitimately +// needs, nothing else. Adding to this list is a security decision. +const SHELL_ALLOWLIST: &[&str] = &[ + "pio", "platformio", + "git", + "npm", "npx", "pnpm", "yarn", + "cargo", "rustc", "rustup", + "node", "python", "python3", "py", + "make", "cmake", "ninja", + "echo", "where", "which", "type", + "dir", "ls", "cd", "pwd", + "cat", "head", "tail", +]; + +const SHELL_METACHARS: &[char] = &[ + '&', '|', ';', '\n', '\r', '$', '`', '(', ')', '{', '}', '<', '>', '!', '~', '*', '?', '[', ']', +]; + +/// Returns the first whitespace-delimited token from `command`, stripped of +/// any leading path components and Windows `.exe`/`.cmd` suffix. Used to +/// match against `SHELL_ALLOWLIST`. +pub fn extract_program(command: &str) -> Option { + let first = command.split_whitespace().next()?; + // Strip drive/path prefix if any + let basename = std::path::Path::new(first) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| first.to_string()); + // Strip trailing extension (Windows) + let stripped = basename + .rsplit_once('.') + .map(|(stem, ext)| { + let lower = ext.to_ascii_lowercase(); + if matches!(lower.as_str(), "exe" | "cmd" | "bat" | "ps1") { + stem.to_string() + } else { + basename.clone() + } + }) + .unwrap_or(basename); + Some(stripped.to_ascii_lowercase()) +} + +/// Validates a `run_shell` invocation against the metacharacter denylist and +/// command allowlist. Returns the canonical program name on success, or an +/// error string suitable for surfacing to the user. +pub fn validate_shell_command(command: &str) -> Result { + let trimmed = command.trim(); + if trimmed.is_empty() { + return Err("Empty shell command.".to_string()); + } + if trimmed.contains(SHELL_METACHARS) { return Err("Shell metacharacters are not allowed.".to_string()); } + let program = extract_program(trimmed) + .ok_or_else(|| "Could not parse shell command.".to_string())?; + if !SHELL_ALLOWLIST.contains(&program.as_str()) { + return Err(format!( + "Command '{}' is not on the allowlist. Allowed: {}", + program, + SHELL_ALLOWLIST.join(", ") + )); + } + Ok(program) +} + +#[command] +pub async fn run_shell(command: String, cwd: Option, root: Option) -> Result { + let program = validate_shell_command(&command)?; + log::info!("run_shell: program={} cwd={:?}", program, cwd); let mut builder = AsyncCommand::new(if cfg!(target_os = "windows") { "cmd" } else { "sh" }); if cfg!(target_os = "windows") { @@ -595,9 +661,151 @@ pub async fn run_shell(command: String, cwd: Option, root: Option out.txt").is_err()); + } + + #[test] + fn shell_rejects_empty_input() { + assert!(validate_shell_command("").is_err()); + assert!(validate_shell_command(" ").is_err()); + } + + // ---- validate_leaf_name ---- + #[test] + fn leaf_name_accepts_simple_names() { + assert!(validate_leaf_name("foo.txt", "file").is_ok()); + assert!(validate_leaf_name("module", "folder").is_ok()); + } + + #[test] + fn leaf_name_rejects_traversal() { + assert!(validate_leaf_name("../etc/passwd", "file").is_err()); + assert!(validate_leaf_name("foo/bar", "file").is_err()); + assert!(validate_leaf_name(".", "file").is_err()); + assert!(validate_leaf_name("..", "file").is_err()); + } + + #[test] + fn leaf_name_rejects_empty() { + assert!(validate_leaf_name("", "file").is_err()); + assert!(validate_leaf_name(" ", "file").is_err()); + } + + // ---- normalize_lexical ---- + #[test] + fn normalize_collapses_parent_dirs() { + let p = normalize_lexical(&PathBuf::from("a/b/../c")); + assert_eq!(p, PathBuf::from("a/c")); + } + + #[test] + fn normalize_drops_curdir() { + let p = normalize_lexical(&PathBuf::from("a/./b")); + assert_eq!(p, PathBuf::from("a/b")); + } + + // ---- validate_path: traversal containment ---- + #[test] + fn validate_path_blocks_traversal_outside_root() { + // Build a sandbox so the test runs anywhere + let tmp = std::env::temp_dir().join(format!( + "embedist_test_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&tmp).unwrap(); + let root = tmp.canonicalize().unwrap(); + let root_str = root.to_string_lossy().to_string(); + + // inside root - ok + let inside = root.join("foo.txt"); + assert!(validate_path(&inside.to_string_lossy(), &root_str).is_ok()); + + // .. escape - blocked + let escape = format!("{}/../../etc/passwd", root_str); + let err = validate_path(&escape, &root_str).unwrap_err(); + assert!(err.contains("outside project root"), "got: {}", err); + + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 58e3a41..13aff90 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Embedist", - "version": "0.37.0", + "version": "0.38.0", "identifier": "com.embedist.embedist", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/AI/AIChatPanel.tsx b/src/components/AI/AIChatPanel.tsx index c1bab6c..bb9be1c 100644 --- a/src/components/AI/AIChatPanel.tsx +++ b/src/components/AI/AIChatPanel.tsx @@ -42,6 +42,23 @@ const MODE_LABELS: Record = { agent: 'Agent', }; +// Phase detection: prefer explicit machine-readable markers, fall back to +// anchored phrase matches near the message end. Bare substring matches like +// "approve" or "?" anywhere in the body produced false positives. +type PlanPhase = 'explore' | 'design' | 'review' | 'clarify' | 'ready'; +const PHASE_MARKER_RE = /\[\[\s*phase\s*:\s*(explore|design|review|clarify|ready)\s*\]\]/i; +const READY_PHRASES = /(plan\s+ready\s+for\s+approval|awaiting\s+approval|ready\s+for\s+approval)\b/i; +const CLARIFY_PHRASES = /(awaiting\s+clarification|need(?:s|ed)?\s+clarification|please\s+clarify)\b/i; + +export function detectPlanPhase(content: string): PlanPhase | null { + const marker = PHASE_MARKER_RE.exec(content); + if (marker) return marker[1].toLowerCase() as PlanPhase; + const tail = content.slice(-400); + if (READY_PHRASES.test(tail)) return 'ready'; + if (CLARIFY_PHRASES.test(tail)) return 'clarify'; + return null; +} + function AIChatPanelContent() { const { mode, @@ -112,9 +129,8 @@ function AIChatPanelContent() { const lastMsg = messages[messages.length - 1]; if (lastMsg.role === 'assistant' && lastMsg.mode === 'plan' && lastMsg.content.trim()) { setPlanContent(lastMsg.content); - if (lastMsg.content.toLowerCase().includes('approve')) { - setPlanPhase('ready'); - } + const phase = detectPlanPhase(lastMsg.content); + if (phase) setPlanPhase(phase); } }, [mode, messages, setPlanContent, setPlanPhase]); @@ -146,11 +162,8 @@ function AIChatPanelContent() { const lastMsg = messages[messages.length - 1]; if (lastMsg.role === 'assistant' && lastMsg.mode === 'agent') { setPlanContent(lastMsg.content); - if (lastMsg.content.toLowerCase().includes('approve')) { - setPlanPhase('ready'); - } else if (lastMsg.content.includes('?')) { - setPlanPhase('clarify'); - } + const phase = detectPlanPhase(lastMsg.content); + if (phase) setPlanPhase(phase); } }, [messages, mode, setPlanContent, setPlanPhase]); diff --git a/src/lib/prompts/modes/plan.md b/src/lib/prompts/modes/plan.md index 8386c4d..1ee84ef 100644 --- a/src/lib/prompts/modes/plan.md +++ b/src/lib/prompts/modes/plan.md @@ -87,6 +87,20 @@ If you encounter ambiguities after research, ask the user specific questions: --- +## Phase Markers (REQUIRED) + +End every response with **exactly one** machine-readable phase marker on its own line. The UI uses this to drive the plan-phase indicator and reveal the Approve button. Do **not** wrap it in code fences. + +- `[[phase: explore]]` — still gathering information about the project/hardware +- `[[phase: design]]` — drafting architecture/milestones +- `[[phase: review]]` — presenting a complete plan, inviting feedback +- `[[phase: clarify]]` — waiting on user answers to questions +- `[[phase: ready]]` — plan is final and ready for the user to approve + +If you forget the marker, the UI falls back to phrase matching ("plan ready for approval", "please clarify") — but the marker is the contract. Prefer the marker. + +--- + ## Output Format Provide structured plans using: diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 134a258..51fca76 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -196,8 +196,15 @@ export const useSettingsStore = create()( toolPermissions: { ...state.toolPermissions, [tool]: permission }, })), }), - { + { name: 'embedist-settings', + // Bump this when the persisted shape changes and add a migration step below. + version: 1, + migrate: (persisted: unknown, _fromVersion: number) => { + // No prior versions exist; this is the baseline. Future bumps should + // branch on `_fromVersion` and reshape `persisted` into the new schema. + return persisted as Partial; + }, partialize: (state) => ({ providers: state.providers, customEndpoints: state.customEndpoints,