From 2c49c0957cd7b9d9b088ed9f35099695f1478524 Mon Sep 17 00:00:00 2001 From: Texas-walker15 Date: Tue, 8 Sep 2026 08:34:13 +0200 Subject: [PATCH 1/4] feat: add read-only coding workspace foundation --- README.md | 82 ++- docs/architecture.md | 112 +++- docs/phase-2-coding-workspace.md | 365 ++++++++++++ docs/security-model.md | 180 +++++- src/main/directory-picker.ts | 53 ++ src/main/index.ts | 7 + src/main/ipc.ts | 268 +++++++++ src/main/workspace-inspector.ts | 456 +++++++++++++++ src/main/workspace-paths.ts | 153 +++++ src/main/workspace-planner.ts | 168 ++++++ src/main/workspace-session.ts | 236 ++++++++ src/preload/index.ts | 62 ++ src/renderer/App.tsx | 35 +- src/renderer/local-agent-bridge.d.ts | 18 + src/renderer/styles.css | 346 +++++++++++ src/renderer/workspace/Workspace.tsx | 458 +++++++++++++++ .../workspace/ipc-workspace-client.ts | 118 ++++ src/renderer/workspace/useWorkspace.ts | 77 +++ .../workspace/workspace-controller.ts | 437 ++++++++++++++ src/shared/constants.ts | 108 +++- src/shared/schemas/index.ts | 79 +++ src/shared/schemas/ipc.schema.ts | 143 +++++ src/shared/schemas/permissions.schema.ts | 24 + src/shared/schemas/workspace.schema.ts | 421 ++++++++++++++ src/shared/workspace/errors.ts | 99 ++++ src/shared/workspace/exclusions.ts | 386 ++++++++++++ src/shared/workspace/index.ts | 44 ++ src/shared/workspace/path-safety.ts | 183 ++++++ src/shared/workspace/plan.ts | 364 ++++++++++++ tests/e2e/electron-smoke.test.ts | 75 ++- tests/unit/main/ipc.test.ts | 468 ++++++++++++++- tests/unit/main/workspace-inspector.test.ts | 442 ++++++++++++++ tests/unit/main/workspace-paths.test.ts | 226 +++++++ tests/unit/main/workspace-planner.test.ts | 151 +++++ tests/unit/main/workspace-session.test.ts | 183 ++++++ .../renderer/workspace-controller.test.ts | 550 ++++++++++++++++++ tests/unit/shared/chat-boundary-scan.test.ts | 70 ++- tests/unit/shared/constants.test.ts | 37 ++ tests/unit/shared/ipc.schema.test.ts | 192 ++++++ .../unit/shared/workspace-exclusions.test.ts | 241 ++++++++ .../unit/shared/workspace-path-safety.test.ts | 220 +++++++ tests/unit/shared/workspace-plan.test.ts | 275 +++++++++ tests/unit/shared/workspace.schema.test.ts | 366 ++++++++++++ 43 files changed, 8896 insertions(+), 82 deletions(-) create mode 100644 docs/phase-2-coding-workspace.md create mode 100644 src/main/directory-picker.ts create mode 100644 src/main/workspace-inspector.ts create mode 100644 src/main/workspace-paths.ts create mode 100644 src/main/workspace-planner.ts create mode 100644 src/main/workspace-session.ts create mode 100644 src/renderer/workspace/Workspace.tsx create mode 100644 src/renderer/workspace/ipc-workspace-client.ts create mode 100644 src/renderer/workspace/useWorkspace.ts create mode 100644 src/renderer/workspace/workspace-controller.ts create mode 100644 src/shared/schemas/workspace.schema.ts create mode 100644 src/shared/workspace/errors.ts create mode 100644 src/shared/workspace/exclusions.ts create mode 100644 src/shared/workspace/index.ts create mode 100644 src/shared/workspace/path-safety.ts create mode 100644 src/shared/workspace/plan.ts create mode 100644 tests/unit/main/workspace-inspector.test.ts create mode 100644 tests/unit/main/workspace-paths.test.ts create mode 100644 tests/unit/main/workspace-planner.test.ts create mode 100644 tests/unit/main/workspace-session.test.ts create mode 100644 tests/unit/renderer/workspace-controller.test.ts create mode 100644 tests/unit/shared/workspace-exclusions.test.ts create mode 100644 tests/unit/shared/workspace-path-safety.test.ts create mode 100644 tests/unit/shared/workspace-plan.test.ts create mode 100644 tests/unit/shared/workspace.schema.test.ts diff --git a/README.md b/README.md index af2ad9c..dc39539 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ A local-first, permission-controlled desktop assistant for Windows. The assistant is named **JARVIS** by default; the product is **Local Agent**. -> **Status: Phase 1 complete. Phase 2, Milestone 4 (provider completion and -> streaming) in progress.** +> **Status: Phase 1 complete. Phase 2, Milestone 5 (coding workspace +> foundation) in progress.** > Phase 1 delivered the hardened desktop shell, non-secret settings storage, > an audit-log foundation, the permission-policy runtime, persisted > emergency-stop state, first-run onboarding, an encrypted secret store, and @@ -33,15 +33,28 @@ assistant is named **JARVIS** by default; the product is **Local Agent**. > refused rather than sent — and reads no credential at all. A streamed > fragment is a preview, never a message: the reply committed to the > conversation is still the whole, separately validated one. `anthropic`, -> `openai`, `claude` and `gemini` remain absent. See +> `openai`, `claude` and `gemini` remain absent. Milestone 5 adds the +> **read-only coding workspace**: the user approves one project directory in a +> native picker the main process owns — the renderer cannot name a directory, +> because `workspace.select` takes no argument at all — and the application can +> then list, read, search and prepare a coding plan inside it, and nothing +> else. Every path is contained twice (lexically, then again after `realpath`, +> so a symbolic link cannot lead out); dependencies, build output and +> credential files are never opened; every listing, file and search is +> bounded; and **nothing in this milestone can modify a file**. That is +> structural rather than a policy: no action type, no IPC channel, no preload +> function and no schema can express a modification. A generated plan is +> inert — its `diff` is pinned to `null`, it always awaits explicit approval, +> and approving it unlocks nothing, because there is nothing to unlock. See > [docs/phase-2-chat-architecture.md](docs/phase-2-chat-architecture.md), > [docs/phase-2-provider-architecture.md](docs/phase-2-provider-architecture.md), -> [docs/phase-2-real-provider-architecture.md](docs/phase-2-real-provider-architecture.md) -> and +> [docs/phase-2-real-provider-architecture.md](docs/phase-2-real-provider-architecture.md), > [docs/phase-2-provider-completion.md](docs/phase-2-provider-completion.md) -> for the full design and for what remains explicitly deferred (tool/action -> execution from model output, memory, and everything else Phase 2 has not -> reached yet). +> and +> [docs/phase-2-coding-workspace.md](docs/phase-2-coding-workspace.md) +> for the full design and for what remains explicitly deferred (file writing, +> terminal execution, Git automation, tool/action execution from model output, +> memory, and everything else Phase 2 has not reached yet). All rights reserved. No licence has been granted for this project. @@ -83,12 +96,16 @@ postponed. - The preload bridge exposes only narrow, explicitly named, typed functions — `localAgent.health`, `localAgent.settings.{get,update}`, `localAgent.secrets.{status,write,clear}`, - `localAgent.chat.{send,cancel,onChunk}` — never `ipcRenderer` itself, never - a generic invoke-any-channel function, and never a generic - listen-to-any-channel one: `chat.onChunk` subscribes to a single fixed, - one-way streaming channel whose payloads are schema-validated in the + `localAgent.chat.{send,cancel,onChunk}`, + `localAgent.workspace.{status,select,tree,file,search,plan}` — never + `ipcRenderer` itself, never a generic invoke-any-channel function, and never + a generic listen-to-any-channel one: `chat.onChunk` subscribes to a single + fixed, one-way streaming channel whose payloads are schema-validated in the preload before any renderer code sees them. None of them can return a - plaintext API key. + plaintext API key, and none of the workspace functions can write, create, + delete or apply anything — `workspace.select` does not even accept a path, + since the directory is chosen by the user in a native dialog the main + process owns. - A strict Content-Security-Policy blocks remote script and network access outright; navigation, `window.open` and `` are all denied. - An action with no matching policy rule is **denied** — enforced both by the @@ -135,13 +152,27 @@ postponed. anything: there is no path from a chat message, typed, streamed or received, to `main/executor.ts` or to an action proposal. +- The coding workspace (Phase 2, Milestone 5) is **read-only, and structurally + so**. The user approves one directory through a native picker the main + process owns; the renderer cannot name a directory, and no action type, IPC + channel, preload function or schema in this milestone can express a + modification. Every renderer-supplied path is validated lexically and then + re-checked after `realpath`, so a symbolic link cannot lead outside the + approved project. Dependencies, build output and credential files are never + opened. Every listing, file read and search is bounded and reports when a + bound stopped it. A generated coding plan is inert: it always awaits explicit + approval, and approving it unlocks nothing. The approved path lives in memory + for one application run and is never written to disk. + Full detail, including known limitations, is in [docs/security-model.md](docs/security-model.md); the chat-specific design is in [docs/phase-2-chat-architecture.md](docs/phase-2-chat-architecture.md), [docs/phase-2-provider-architecture.md](docs/phase-2-provider-architecture.md), [docs/phase-2-real-provider-architecture.md](docs/phase-2-real-provider-architecture.md) and -[docs/phase-2-provider-completion.md](docs/phase-2-provider-completion.md). +[docs/phase-2-provider-completion.md](docs/phase-2-provider-completion.md); +the workspace design is in +[docs/phase-2-coding-workspace.md](docs/phase-2-coding-workspace.md). ## Where your data lives @@ -211,8 +242,10 @@ src/shared/ Pure schemas, types and constants, plus chat/ (the deterministic mock provider, the approved-provider registry with its fail-closed adapters, the composable timeout decorator, and the local-endpoint classifier that keeps - Ollama local). No I/O, no Electron, no network — safe to - import from any process, including the renderer. + Ollama local), plus workspace/ (the lexical path-safety rules, + the exclusion lists, the normalized error vocabulary, and the + pure coding-plan builder). No I/O, no Electron, no network — + safe to import from any process, including the renderer. src/main/ Privileged Electron main process. Owns the BrowserWindow, the Content-Security-Policy, navigation/window-open/webview hardening, non-secret settings storage (paths.ts, @@ -231,7 +264,12 @@ src/main/ Privileged Electron main process. Owns the BrowserWindow, ollama-provider.ts — the last enforced local), the main-process provider registry that resolves them from settings and the encrypted secret store - (chat-provider-registry.ts), and the eight registered IPC + (chat-provider-registry.ts), the read-only coding workspace + (directory-picker.ts — the native project picker; + workspace-paths.ts — canonical path containment; + workspace-session.ts — the approved project, in memory only; + workspace-inspector.ts — the only module that reads a user's + file; workspace-planner.ts), and the fourteen registered IPC channels (ipc.ts), of which chat:chunk is the only main-to-renderer event. src/preload/ The single contextBridge. Exposes a narrow, explicitly @@ -246,9 +284,13 @@ src/renderer/ React interface: App.tsx gates on onboardingCompleted, provider is active — and, since Milestone 3, ipc-chat-provider.ts, the one file in this directory permitted to call window.localAgent, reaching the real - provider through chat.send/chat.cancel). No Node, no - Electron, no direct filesystem or network access anywhere - else in this directory — only the bridge at window.localAgent. + provider through chat.send/chat.cancel), and workspace/ is the + Milestone 5 read-only coding surface (Workspace.tsx, the + framework-independent workspace-controller.ts, useWorkspace.ts, + and ipc-workspace-client.ts — the second and only other file + permitted to call window.localAgent). No Node, no Electron, no + direct filesystem or network access anywhere else in this + directory — only the bridge at window.localAgent. tests/unit/ Unit tests. `npm test`. tests/e2e/ Playwright + Electron smoke test against the built app. `npm run test:e2e`. diff --git a/docs/architecture.md b/docs/architecture.md index 287dbd3..3507750 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,12 @@ > (`src/renderer/Onboarding.tsx`). `main/permissions.ts`, > `main/action-pipeline.ts` and `main/executor.ts` again needed **zero code > changes** — every new channel is routed through the unmodified -> `handleActionProposal`. This document marks which parts exist today. +> `handleActionProposal`. Phase 2 Milestone 5 adds the read-only coding +> workspace (`main/directory-picker.ts`, `main/workspace-paths.ts`, +> `main/workspace-session.ts`, `main/workspace-inspector.ts`, +> `main/workspace-planner.ts`) and six more channels, under a permission +> engine, executor, pipeline and audit writer that once again have **zero +> diff**. This document marks which parts exist today. --- @@ -60,36 +65,49 @@ mechanism to. With `sandbox: true`, `contextIsolation: true` and `nodeIntegration: false`, a fully compromised renderer gains only the narrow preload API: `health`, `settings.get`/`settings.update`, `secrets.status`/`secrets.write`/`secrets.clear` as of Milestone 7, -`chat.send`/`chat.cancel` as of Phase 2 Milestone 3, and `chat.onChunk` as of -Phase 2 Milestone 4 — every one of them except the last two policy-gated and -audited before it can perform a privileged action, and none of them able to -return a plaintext key. The two exceptions carry no authority of their own: -`chat.cancel` can only ask an already-authorized call to stop early, and -`chat.onChunk` only subscribes to bounded, validated preview text for a -`chat.send` the renderer itself initiated — see +`chat.send`/`chat.cancel` as of Phase 2 Milestone 3, `chat.onChunk` as of +Phase 2 Milestone 4, and +`workspace.status`/`select`/`tree`/`file`/`search`/`plan` as of Phase 2 +Milestone 5 — every one of them except `chat.cancel` and `chat.onChunk` +policy-gated and audited before it can perform a privileged action, and none +of them able to return a plaintext key. The two exceptions carry no authority +of their own: `chat.cancel` can only ask an already-authorized call to stop +early, and `chat.onChunk` only subscribes to bounded, validated preview text +for a `chat.send` the renderer itself initiated — see `docs/phase-2-real-provider-architecture.md` and `docs/phase-2-provider-completion.md`. +The six workspace functions read, and only read. None of them can name a +directory to open — `workspace.select` takes no argument at all, because the +user chooses in a native dialog the main process owns — and none of them can +write, create, delete or apply anything, because no such function exists to +call. See `docs/phase-2-coding-workspace.md`. + ## Modules -| Module | Responsibility | Must not | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `shared` **(exists)** | Zod schemas, derived types, constants. Pure data and pure functions. | Perform I/O; import Electron or Node built-ins; depend on `main`, `preload` or `renderer`. | -| `renderer` **(exists)** | All interface, including first-run onboarding. Renders state, collects input, sends requests over the bridge. | Touch Node APIs, the filesystem or `ipcRenderer`; read secrets; load remote content. | -| `preload` **(exists)** | The single bridge. Exposes an explicitly enumerated, typed API via `contextBridge`. | Expose `ipcRenderer`; provide a generic "invoke any channel" function; expose a Node primitive. | -| `main/ipc` **(exists)** | Receives every request, validates its payload and its response against a schema, and routes it through `main/action-runtime.ts`. | Execute anything itself; call `execute` or `main/secrets.ts` directly, bypassing the permission engine. | -| `main/action-runtime` **(exists)** | Loads the current policy and emergency state fresh per request and calls `handleActionProposal`. | Add a decision step of its own; cache policy or emergency state across requests. | -| `main/confirm` **(exists)** | `showNativeConfirmation` — the real `dialog.showMessageBox`, parented to the main window. | Be called from the renderer; be rendered as HTML; describe a secret in the dialog text. | -| `main/permissions` **(exists)** | Pure decision function. `(proposal, policy, emergencyState) → verdict`. | Perform I/O, show dialogs, or log. | -| `main/executor` **(exists)** | The **only** module that performs side effects. Requires a permission verdict as an argument. | Be called from `renderer` or `preload`; act without a decision; make its own policy judgements. | -| `main/action-pipeline` **(exists)** | Assembles `permissions → [confirm] → executor → audit` into one function every IPC handler calls. | Be bypassed by a handler that wires the pieces together itself. | -| `main/audit` **(exists)** | Append-only event writer with redaction and daily rotation. | Expose any update or delete function; write an unredacted secret. | -| `main/settings` **(exists)** | Loads, validates and atomically writes settings. Fails closed to safe defaults. | Store secrets; write without going through the executor. | -| `main/settings-service` **(exists)** | Reconciles `hasApiKey` against the secret store on every read and write; assembles onboarding/provider settings writes. | Accept `hasApiKey` as caller input; skip re-validating the full document before persisting. | -| `main/secrets` **(exists)** | Encrypts and decrypts via the **synchronous** `safeStorage` API (Windows DPAPI). Presence is answered from the file's shape, never by decrypting. | Return a plaintext secret across IPC; log a secret; fall back to storing plaintext. | -| `main/policy` **(exists)** | Loads and validates the permission policy file. Fails closed to `createDefaultPermissionPolicy()`. | Merge a partially-valid document; expose a write path. | -| `main/emergency` **(exists)** | Loads and atomically persists emergency-stop state; `engageEmergencyStop`/`resetEmergencyStop` are `perform` callbacks for the action pipeline. | Be bypassable by the renderer, a model, or the policy file; make its own permission decision. | -| `main/paths` **(exists)** | Single source of truth for user-data locations. | Accept a user-supplied path. | +| Module | Responsibility | Must not | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `shared` **(exists)** | Zod schemas, derived types, constants. Pure data and pure functions. | Perform I/O; import Electron or Node built-ins; depend on `main`, `preload` or `renderer`. | +| `renderer` **(exists)** | All interface, including first-run onboarding. Renders state, collects input, sends requests over the bridge. | Touch Node APIs, the filesystem or `ipcRenderer`; read secrets; load remote content. | +| `preload` **(exists)** | The single bridge. Exposes an explicitly enumerated, typed API via `contextBridge`. | Expose `ipcRenderer`; provide a generic "invoke any channel" function; expose a Node primitive. | +| `main/ipc` **(exists)** | Receives every request, validates its payload and its response against a schema, and routes it through `main/action-runtime.ts`. | Execute anything itself; call `execute` or `main/secrets.ts` directly, bypassing the permission engine. | +| `main/action-runtime` **(exists)** | Loads the current policy and emergency state fresh per request and calls `handleActionProposal`. | Add a decision step of its own; cache policy or emergency state across requests. | +| `main/confirm` **(exists)** | `showNativeConfirmation` — the real `dialog.showMessageBox`, parented to the main window. | Be called from the renderer; be rendered as HTML; describe a secret in the dialog text. | +| `main/permissions` **(exists)** | Pure decision function. `(proposal, policy, emergencyState) → verdict`. | Perform I/O, show dialogs, or log. | +| `main/executor` **(exists)** | The **only** module that performs side effects. Requires a permission verdict as an argument. | Be called from `renderer` or `preload`; act without a decision; make its own policy judgements. | +| `main/action-pipeline` **(exists)** | Assembles `permissions → [confirm] → executor → audit` into one function every IPC handler calls. | Be bypassed by a handler that wires the pieces together itself. | +| `main/audit` **(exists)** | Append-only event writer with redaction and daily rotation. | Expose any update or delete function; write an unredacted secret. | +| `main/settings` **(exists)** | Loads, validates and atomically writes settings. Fails closed to safe defaults. | Store secrets; write without going through the executor. | +| `main/settings-service` **(exists)** | Reconciles `hasApiKey` against the secret store on every read and write; assembles onboarding/provider settings writes. | Accept `hasApiKey` as caller input; skip re-validating the full document before persisting. | +| `main/secrets` **(exists)** | Encrypts and decrypts via the **synchronous** `safeStorage` API (Windows DPAPI). Presence is answered from the file's shape, never by decrypting. | Return a plaintext secret across IPC; log a secret; fall back to storing plaintext. | +| `main/policy` **(exists)** | Loads and validates the permission policy file. Fails closed to `createDefaultPermissionPolicy()`. | Merge a partially-valid document; expose a write path. | +| `main/emergency` **(exists)** | Loads and atomically persists emergency-stop state; `engageEmergencyStop`/`resetEmergencyStop` are `perform` callbacks for the action pipeline. | Be bypassable by the renderer, a model, or the policy file; make its own permission decision. | +| `main/paths` **(exists)** | Single source of truth for user-data locations. | Accept a user-supplied path. | +| `main/directory-picker` **(exists)** | `showDirectoryPicker` — the real `dialog.showOpenDialog`. Takes no path, so the user alone chooses which directory becomes readable. | Be called from the renderer; accept, suggest or remember a location; create a directory. | +| `main/workspace-paths` **(exists)** | Canonical containment: join, check, `realpath`, check again. The layer that catches a symbolic link. | Trust a lexical check alone; put a path or an `errno` into a thrown error. | +| `main/workspace-session` **(exists)** | Holds the one approved project, in memory, for one application run. | Persist the approved path; accept a project that was not canonicalised and validated. | +| `main/workspace-inspector` **(exists)** | The only module that reads a user's own file: list, read, search. Bounded in every dimension. | Write, create, rename or delete anything; log; follow a link out; read an excluded path. | +| `main/workspace-planner` **(exists)** | Gathers observations about the approved project and calls the pure plan builder. | Call a model; produce content that could be applied; read the clock. | ## Dependency direction @@ -718,3 +736,45 @@ request — the permission decision, the audit record, `updatedAt` on a written All persisted timestamps are UTC ISO-8601 and reject a UTC offset, so records sort correctly and compare unambiguously. + +`main/workspace-session.ts` and `main/workspace-planner.ts` follow the same +convention: `adoptProjectDirectory({now})` and `createCodingPlan(project, +objective, now)` both take the timestamp from the caller, so a generated plan +is byte-for-byte reproducible in a test. `main/workspace-inspector.ts` reads no +clock at all. + +## Coding workspace (implemented, Phase 2 Milestone 5) + +| File | Describes | +| --------------------------------- | ----------------------------------------------------------------------------- | +| `src/main/directory-picker.ts` | The native project picker. Takes no path. | +| `src/main/workspace-paths.ts` | `isContainedPath`, `resolveProjectPath`, `toWorkspaceError` | +| `src/main/workspace-session.ts` | `createWorkspaceSession`, `adoptProjectDirectory`, `requireApprovedProject` | +| `src/main/workspace-inspector.ts` | `listProjectTree`, `readProjectFile`, `searchProject` | +| `src/main/workspace-planner.ts` | `gatherPlanObservations`, `createCodingPlan` | +| `src/shared/workspace/` | The pure half: path rules, exclusions, error vocabulary, the plan builder | +| `src/renderer/workspace/` | `Workspace.tsx`, `workspace-controller.ts`, `useWorkspace.ts`, the IPC client | + +**No engine, pipeline or audit code changed.** Three new action types +(`workspace.select`, `workspace.read`, `workspace.plan`) and six new channels +are routed through the same unmodified `runAction` → `handleActionProposal` +every action-backed channel already used — the fourth milestone in a row for +which that has been true. + +**The pure/impure split matches `decidePermission`'s.** What a plan _says_ is +computed by `buildCodingPlan` in `src/shared/workspace/plan.ts` — no I/O, no +clock, no model, fully testable without a filesystem. What is _true of a +project_ is measured by `main/workspace-planner.ts`, under the same bounds +every other workspace read obeys. The same split puts the lexical path rules +in `src/shared` and the `realpath` containment check in `src/main`, so both +processes apply one rule rather than two that can drift. + +**The renderer's workspace state is framework-independent**, exactly like +`ConversationController`: `WorkspaceController` holds every transition — +loading, empty, error, retry, staleness, the approval gate — and +`Workspace.tsx` holds none, which is what makes the interface's behaviour +testable in a toolchain with no jsdom. + +Full design, including why the plan is derived rather than model-generated and +why the picker takes no argument, is in +[phase-2-coding-workspace.md](phase-2-coding-workspace.md). diff --git a/docs/phase-2-coding-workspace.md b/docs/phase-2-coding-workspace.md new file mode 100644 index 0000000..07183bf --- /dev/null +++ b/docs/phase-2-coding-workspace.md @@ -0,0 +1,365 @@ +# Phase 2 coding workspace + +> **Current state.** Phase 2, Milestone 5 adds a **read-only** coding +> workspace: the user approves one project directory in a native picker, and +> the application can list, read, search and reason about what is inside it — +> and nothing else. There is no write, no patch, no terminal, no Git command +> and no shell. That absence is structural, not a policy: no action type, no +> IPC channel, no preload function and no schema in this milestone can express +> a modification. +> +> Builds on [phase-2-chat-architecture.md](phase-2-chat-architecture.md), +> [phase-2-provider-architecture.md](phase-2-provider-architecture.md), +> [phase-2-real-provider-architecture.md](phase-2-real-provider-architecture.md) +> and [phase-2-provider-completion.md](phase-2-provider-completion.md), none of +> which changed. `main/permissions.ts`, `main/executor.ts`, +> `main/action-pipeline.ts`, `main/action-runtime.ts`, `main/audit.ts`, +> `main/emergency.ts`, `main/secrets.ts`, `main/settings.ts` and +> `main/policy.ts` all have **zero diff** in this milestone. + +--- + +## Summary + +| Property | State | +| --------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Project selection | **[implemented]** — native `dialog.showOpenDialog`, owned by the main process | +| Renderer can name a directory to open | **Never.** `workspace:select` takes no arguments at all | +| Read-only listing, viewing, searching | **[implemented]** — `src/main/workspace-inspector.ts` | +| Path containment | **[implemented]** — two independent layers, lexical then `realpath` | +| Symbolic-link escape | **[implemented]** — refused after canonicalisation; the tree walk skips links entirely | +| Credential and dependency exclusion | **[implemented]** — `src/shared/workspace/exclusions.ts` | +| Coding plan | **[implemented]** — deterministic, derived from observation; **no model call** | +| Plan can be applied | **Never.** `diff` is pinned to `null`, `status` to `'awaiting-approval'`, and nothing consumes one | +| New action types | **[implemented]** — `workspace.select`, `workspace.read`, `workspace.plan` | +| Emergency stop blocks workspace actions | **[implemented]** — none is on the exemption list | +| Approved project persisted to disk | **Never.** In memory, for the lifetime of one application run | +| File writing, terminal, Git, shell | **Absent.** See [What remains deferred](#what-remains-deferred) | + +--- + +## The decision this milestone turns on + +A read-only file browser sounds like a small feature. It is not: it is the +first time this application reads a file the user's own machine did not put +under `%APPDATA%\Local-Agent\`, and the first time a directory tree written by +someone else becomes input. `AGENTS.md` §5 already classes such content as +untrusted, in the same category as model output. + +So the design question was never "how do we read files" but "what, exactly, is +the smallest thing that can be read, and who decides". Three answers shape +everything below. + +### 1. The user chooses the directory, in a dialog the renderer cannot reach + +`workspace:select` takes **no arguments**. Not a path it validates — none at +all. The main process opens `dialog.showOpenDialog` (`main/directory-picker.ts`), +the user clicks, and whatever comes back is the only candidate. + +This is the same reasoning that makes `main/confirm.ts`'s confirmation dialog +native rather than HTML: a renderer, or anything that has compromised one, can +ask that the user be asked, and nothing more. It cannot pre-fill the dialog, +suppress it, answer it, or point it anywhere. + +It is also why `workspace.select` is `allow` by default rather than being +added to the confirmation floor. A second, HTML-triggered confirmation before a +native dialog would be ceremony: the picker **is** the consent, and it is a +stronger form of consent than `chat.send`'s (which this codebase already +accepted as sufficient, on the weaker grounds that the user typed and pressed +Send in the renderer). + +### 2. Two containment layers, because neither is sufficient alone + +Every path the renderer sends is _relative_ to the approved root, and passes +through both: + +| Layer | Where | Catches | +| --------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Lexical (pure, no filesystem) | `src/shared/workspace/path-safety.ts` | `..`, `.`, absolute paths, UNC, drive letters, backslashes, NTFS streams, device names, bidi, NUL | +| Canonical (`realpath`, then re-checked) | `src/main/workspace-paths.ts` | Symbolic links leading outside the project — invisible to any lexical rule | + +A lexical rule cannot see a link: `src/notes` may be perfectly well-formed and +still point at `C:\Users\me\.ssh`. A `realpath` check cannot run on a string +that has not been joined onto a root yet — and joining is exactly the step an +absolute path or a `..` segment subverts. So the order is: validate lexically, +refuse excluded segments, join, check containment, canonicalise, **check +containment again**. + +The canonical form is deliberately narrow — `/`-separated, relative, no `.` or +`..`, no `\`, no `:`, no wildcard, no trailing dot or space, no Windows device +name. Every path the renderer ever sends was produced by this codebase's own +listing in exactly that form, so refusing everything else costs nothing. + +Three of those rules are worth naming because they are not obvious: + +- **`\` is refused.** Without it, `..\..\Windows` is a single segment that is + neither `.` nor `..`, so it passes a `/`-oriented segment check — while + `path.resolve` still treats the backslashes as separators on Windows. (Lint + caught a collapsed escape here during development, which is exactly the class + of bug the second containment layer exists to survive.) +- **`:` is refused**, which blocks a drive letter and, less obviously, + `notes.txt:hidden` — an NTFS alternate data stream, a different file that no + listing would ever have shown. +- **A trailing dot or space is refused.** Windows silently strips both, so + `secrets.` and `secrets` name the same file: a second spelling around every + name-based rule. + +### 3. The plan is derived, not generated + +`workspace:plan` produces its plan **deterministically**, from what was +observed about the project. It calls no model. Two reasons: + +- A model-written plan is untrusted text that would then have to be parsed back + into a structure — new attack surface, in the exact place this milestone is + supposed to be establishing that model output is data and never instruction, + and for no gain while nothing can act on a plan anyway. +- It would make the workspace unusable whenever `modelProvider.provider` is + `none`, which is the default — so the approval gate could not be demonstrated + or reviewed without first configuring a provider. + +Every risk and assumption a plan states is _observed_: emitted because a +specific fact about the project was, or was not, found. "No test tooling was +detected" appears only when no test marker exists at the root. A later +milestone that wants a model to elaborate on a plan passes this structure to it +and validates what comes back against `codingPlanSchema` — the seam is the +schema, and it already exists. + +--- + +## Modules + +| File | Responsibility | +| ------------------------------------------------ | -------------------------------------------------------------------------------------- | +| `src/shared/workspace/path-safety.ts` | Pure lexical path validation. The first containment layer. | +| `src/shared/workspace/exclusions.ts` | What is never descended into, read or searched, and why. | +| `src/shared/workspace/errors.ts` | The twelve-code normalized error vocabulary, and the fixed messages that never travel. | +| `src/shared/workspace/plan.ts` | The pure plan builder. No I/O, no clock, no model. | +| `src/shared/schemas/workspace.schema.ts` | Every shape that crosses a boundary, all bounded. | +| `src/main/directory-picker.ts` | The native picker. Takes no path. | +| `src/main/workspace-paths.ts` | Canonical containment. The second layer. | +| `src/main/workspace-session.ts` | The approved project, in memory, for this run only. | +| `src/main/workspace-inspector.ts` | The only module that reads a user's file: tree, read, search. | +| `src/main/workspace-planner.ts` | Gathers observations, calls the pure builder. | +| `src/renderer/workspace/ipc-workspace-client.ts` | The one renderer file permitted to call `window.localAgent`. | +| `src/renderer/workspace/workspace-controller.ts` | Framework-independent state: loading, empty, error, retry, approval. | +| `src/renderer/workspace/Workspace.tsx` | The interface. Holds no logic of its own. | + +--- + +## Bounds + +Nothing accumulates without a cap. Every one is a named constant in +`src/shared/constants.ts`, and every one reports itself rather than silently +shortening a result — a listing that quietly omits half a project is one a +reader would draw the wrong conclusion from. + +| Dimension | Constant | Value | +| -------------------------- | ------------------------------------- | ------------ | +| Relative path length | `WORKSPACE_MAX_RELATIVE_PATH_LENGTH` | 512 | +| Path segments | `WORKSPACE_MAX_PATH_SEGMENTS` | 32 | +| Tree depth | `WORKSPACE_MAX_TREE_DEPTH` | 8 | +| Tree entries | `WORKSPACE_MAX_TREE_ENTRIES` | 2,000 | +| Entries per directory | `WORKSPACE_MAX_DIRECTORY_ENTRIES` | 500 | +| File size (**refused**) | `WORKSPACE_MAX_FILE_BYTES` | 512,000 | +| Binary sniff sample | `WORKSPACE_BINARY_SNIFF_BYTES` | 8,192 | +| Search matches | `WORKSPACE_MAX_SEARCH_RESULTS` | 200 | +| Files opened by one search | `WORKSPACE_MAX_SEARCH_FILES` | 1,000 | +| File size searched | `WORKSPACE_SEARCH_MAX_FILE_BYTES` | 256,000 | +| Match excerpt | `WORKSPACE_SEARCH_EXCERPT_MAX_LENGTH` | 240 | +| Objective | `WORKSPACE_OBJECTIVE_MAX_LENGTH` | 2,000 | +| Plan steps / files / risks | `WORKSPACE_PLAN_MAX_*` | 12 / 20 / 12 | +| Search terms used to plan | `WORKSPACE_PLAN_MAX_SEARCH_TERMS` | 3 | + +An oversized file is **refused, not truncated** — the same reasoning the audit +log applies to an oversized record: a partial source file invites a reader to +conclude something from text that was silently cut off. + +--- + +## Exclusions + +Three lists, for three different reasons, kept separate so the rationale for +each stays readable (`src/shared/workspace/exclusions.ts`): + +- **Excluded directories** — `node_modules`, `.git`, `dist`, `build`, + `coverage`, `.venv`, `.ssh`, `.aws`, and the rest. Excluded because they are + enormous or are not the user's source. They are still **listed**, marked + `excluded`, and never descended into — hiding them would make the tree + disagree with what is on disk. +- **Credential files** — `.env*`, `.netrc`, `.npmrc`, `id_rsa`, `*.pem`, + `*.p12`, `secrets.json`. Never listed as readable, never opened, never + searched. Reading one would put a live secret into renderer memory and onto a + screen. +- **Binary extensions** — a cheap pre-filter. Never the only check: a NUL byte + in the first bytes read, and a failed strict UTF-8 decode, are what actually + decide, since an extension is only a claim a file makes about itself. + +The one subtlety worth stating: the credential-stem rule (`secrets.*`, +`credentials.*`) is **skipped for source and documentation extensions**. This +repository is its own motivating example — `src/main/secrets.ts` and +`src/shared/schemas/secrets.schema.ts` are exactly the files a reader would +want to open when inspecting how secrets are handled, and hiding them would +make the inspector actively misleading about the project it is showing. The +stem rule applies to data files; a credential pasted into a `.ts` file is not +caught, which is the same limitation the audit log's name-based redaction +already has. + +**`.git` is detected without being read.** Whether a project is +version-controlled comes from the mere existence of the entry, so the interface +learns it without anything listing or reading one byte inside. + +--- + +## IPC and the permission pipeline + +Six channels, all read-only, all routed through the unmodified +`runAction` → `handleActionProposal`: + +| Channel | Action | Request | +| ------------------ | ------------------ | ---------------------- | +| `workspace:status` | `workspace.read` | none | +| `workspace:select` | `workspace.select` | **none** | +| `workspace:tree` | `workspace.read` | `{path}` (`''` = root) | +| `workspace:file` | `workspace.read` | `{path}` | +| `workspace:search` | `workspace.read` | `{query, path}` | +| `workspace:plan` | `workspace.plan` | `{objective}` | + +Three action types rather than one, because they are three genuinely different +decisions a user might want to make separately: granting read access to a +directory tree, reading inside one already granted, and preparing the plan that +a future modification would have to pass through. All three are `allow` by +default and **none is emergency-stop exempt**, so an engaged stop denies every +one of them — asserted by a test that walks all six channels. + +**Audit parameters carry no path, no name, no query and no objective.** Only +`{operation}` and, where useful, a _length_: `{operation: 'search', +queryLength: 6}`, `{operation: 'plan', objectiveLength: 22}`. A directory path +can name a person, a client or an unreleased product; a search term is user +content. This follows `chat.send`'s own precedent of recording +`{provider, messageCount}` rather than the conversation, and a test asserts the +project path never appears in any record. + +--- + +## What the interface shows, and how it stays safe + +`src/renderer/workspace/Workspace.tsx` renders the project summary, a file +tree, a search box, a read-only viewer, file metadata, and the plan. Loading, +empty, error and retry states all come from `WorkspaceController`, which is +framework-independent and fully unit-tested without React or jsdom — the same +split `ConversationController` already uses, and for the same reason +([security-model.md](security-model.md) known limitation 18). + +- **Everything from the project is a plain JSX text child.** File names, paths, + excerpts and file contents alike. `dangerouslySetInnerHTML` appears nowhere. +- **No main-process message is ever displayed.** Failures cross as a bounded + `WorkspaceErrorCode`; the sentence a person reads is one of the controller's + own reviewed strings. A denial gets its own message naming the emergency stop + and the permission policy, rather than being folded into "could not read". +- **Retry is offered only where retrying could help.** An excluded path or an + oversized file answers the same next time, so no Retry button is shown for + them. +- **Bidirectional overrides are handled asymmetrically, deliberately.** A file + name carrying one is refused outright (it could disguise an extension); a + _search excerpt_ is sanitized (it is already a lossy fragment, and a results + list is where a reordered line would be most convincing); a _file's contents_ + are shown exactly as stored, with a warning banner — refusing would make + Arabic, Hebrew and Persian source unopenable, and rewriting it would mean the + viewer lied about what is on disk. +- **The read-only marker is the loudest thing in the header**, and the plan's + approval control says plainly that approving unlocks nothing. + +--- + +## The approval gate + +`WorkspaceController.approvePlan()` sets a flag and does nothing else. There is +no modification function for it to unlock, in that module or anywhere else in +this milestone. + +That is the point. The requirement — explicit user approval before any future +modification — is implemented as a real, tested state transition rather than a +promise, so whichever milestone adds a modification has a gate already in place +to route through rather than one to invent. Approval resets for every newly +generated plan, so it is never inherited by a plan the user has not seen, and +the interface states outright that nothing was changed. + +`codingPlanSchema` backs this at the schema level: `diff` is pinned to `null` +(not omitted), `approvalRequired` to `true`, `status` to `'awaiting-approval'`, +and `changeType` admits only `'modify'` and `'review'` — never `'create'` or +`'delete'`. A later edit that starts producing a diff has to change the schema, +and therefore has to be reviewed. + +--- + +## Security boundary + +Everything Milestones 1–4 established holds unchanged. New in this milestone: + +- **All filesystem access is in `src/main`.** `src/shared/workspace/*` is bound + by the same lint purity boundary as the rest of `src/shared`; the renderer + reaches the disk only through six narrow, schema-validated channels. +- **The renderer cannot name a directory to open.** `workspace:select` has no + argument, asserted at the schema, at the IPC layer, and against the real + built bridge. +- **Nothing can express a modification.** No action type, no channel, no + preload function, no schema field. Tests assert the absence at each layer, + including against the real running application's bridge. +- **The inspector never writes and never logs.** Proven, not asserted: a test + snapshots the project before and after a full listing, read, search and plan + — including when every operation fails — and another spies on + `console.log`/`error`/`warn` across the same. +- **The approved path is never persisted.** Not to `settings.json`, not to the + secret store, not across restarts. Read access to someone's source tree is a + per-session grant made by clicking through a dialog, not a preference to be + restored silently on the next launch. +- **The application's own data directory is refused as a project**, in both + directions (inside it, or containing it). Both sides of the comparison are + canonicalised first — a short (8.3) Windows ancestor such as + `C:\Users\VUNHAT~1\…` would otherwise share no common root with its long + form and let the guard pass silently. That was a real defect, found by a test. +- **The two renderer bridge callers are named and enforced.** A source scan + asserts `window.localAgent` appears in exactly two files — + `ipc-chat-provider.ts` and `ipc-workspace-client.ts` — and nowhere under + `src/shared`. + +--- + +## Testing + +| Area | File | +| --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| Lexical containment: traversal, absolute, UNC, drive letters, backslashes, NTFS streams, device names, bidi | `tests/unit/shared/workspace-path-safety.test.ts` | +| Exclusions, in both directions: what must not be read, and what must stay readable | `tests/unit/shared/workspace-exclusions.test.ts` | +| Plan building: determinism, observed risks, bounds, and the no-diff/awaiting-approval guarantees | `tests/unit/shared/workspace-plan.test.ts` | +| Every boundary shape, including the plan shapes that cannot describe a modification | `tests/unit/shared/workspace.schema.test.ts` | +| Canonical containment and **symlink escape**, against a real filesystem | `tests/unit/main/workspace-paths.test.ts` | +| Project adoption, refusals, and the in-memory session | `tests/unit/main/workspace-session.test.ts` | +| Tree, read and search against a real project; size, binary, excluded and missing handling; **no write, no log** | `tests/unit/main/workspace-inspector.test.ts` | +| Observations measured from a real project; plan changes nothing on disk | `tests/unit/main/workspace-planner.test.ts` | +| Loading, empty, error, retry, staleness, the approval gate | `tests/unit/renderer/workspace-controller.test.ts` | +| Permission gating, emergency-stop denial, request validation, audit content | `tests/unit/main/ipc.test.ts` | +| Request/response schemas and the absence of any write-shaped field | `tests/unit/shared/ipc.schema.test.ts` | +| Bridge shape, and no workspace mutator, against the real built app | `tests/e2e/electron-smoke.test.ts` | + +The symlink-escape cases create real links and detect a platform that refuses +(Windows needs Developer Mode or elevation), reporting a skip rather than a +false pass. They were genuinely exercised on the development machine. The same +escape is _also_ covered lexically, without any symlink. + +--- + +## What remains deferred + +Genuinely absent, not merely unused — there is no action type, channel, schema +or function for any of it: + +- File writing, code modification, patch application +- Terminal, PowerShell, Command Prompt, shell commands +- Git commits, branches, resets, checkouts; dependency installation +- Browser automation, Windows automation, screen capture, camera, email +- Memory, embeddings, RAG, workflows, multi-agent execution +- Administrator or elevated execution +- Unrestricted filesystem access +- A model-assisted plan (the schema seam exists; nothing uses it) +- A diff or proposed file content of any kind diff --git a/docs/security-model.md b/docs/security-model.md index fde18c0..36d4940 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -23,9 +23,14 @@ > and Ollama, the latter refused unless its endpoint is a local address) and > adds bounded streaming, introducing `chat:chunk` — the first and only > main → renderer _push_ channel — while adding **no** new action type, no -> new permission decision and no new secret-store access. Controls below are -> marked **[implemented]**, **[enforced by schema]** or **[planned, milestone -> N]**. Nothing here is claimed as working before it exists. +> new permission decision and no new secret-store access. Phase 2, Milestone 5 +> adds the **read-only coding workspace** — the first time this application +> reads a file outside `%APPDATA%\Local-Agent\` — under three new action types +> (`workspace.select`, `workspace.read`, `workspace.plan`), six new channels, +> and a permission engine, executor, pipeline and audit writer that once again +> needed **zero code changes**. Controls below are marked **[implemented]**, +> **[enforced by schema]** or **[planned, milestone N]**. Nothing here is +> claimed as working before it exists. --- @@ -217,6 +222,107 @@ direct, per-message user action, unlike the rare, one-time changes the confirmation floor (`secrets.write`, `secrets.clear`, `emergency.reset`, `app.exit`) protects. +### Filesystem access + +**[implemented, Phase 2 Milestone 5]** Before this milestone, every path this +application touched derived from `app.getPath('appData')` and none was +user-supplied — "path traversal: not reachable" was literally true. The coding +workspace changes that, so the controls that make it safe are stated in full. + +**The renderer cannot name a directory.** `workspace:select` takes no +arguments at all — not a path it validates, none. `main/directory-picker.ts` +opens `dialog.showOpenDialog` and whatever the _user_ clicks is the only +candidate, which is then still canonicalised, confirmed to be a directory, and +refused if it is (or contains) the application's own data directory. A +compromised renderer can ask that the user be asked, and nothing more. This is +the same reasoning that makes the confirmation dialog native rather than HTML, +and it is why `workspace.select` is `allow` by default: the picker **is** the +consent, and a stronger form of it than a renderer-side click. + +**Containment is enforced twice, by two mechanisms that fail differently.** +Every renderer-supplied path is relative to the approved root and passes: + +1. `src/shared/workspace/path-safety.ts` — pure and lexical. Refuses `..`, + `.`, absolute paths, UNC prefixes, drive letters, `\` (without which + `..\..\Windows` is a single segment that passes a `/`-oriented check while + `path.resolve` still treats it as separators), `:` (a drive letter, and the + NTFS alternate-data-stream separator — `notes.txt:hidden` is a different + file no listing ever showed), wildcards, control characters, bidirectional + overrides, Windows reserved device names, and any trailing dot or space + (Windows strips both, so `secrets.` and `secrets` are the same file — a + second spelling around every name-based rule). +2. `src/main/workspace-paths.ts` — joins onto the real root, checks + containment, canonicalises with `realpath`, and **checks containment + again**. This is the check that catches a symbolic link: a lexical rule + cannot see one, and `realpath` cannot run on a string that has not been + joined onto a root yet. Verified against a real filesystem with real links. + +Containment comparison uses `path.relative`, not a string prefix: on Windows +that compares case-insensitively and normalises separators, and it does not +mistake `…\project-backup` for something inside `…\project`. + +**The tree walk never follows a link.** It keeps only entries that are a +regular file or a real directory, which excludes symbolic links (and with them +directory loops and escapes), sockets, FIFOs and device nodes. A symlinked file +can still be opened directly by path, where the canonical check proves it stays +inside; the walk simply does not offer it. + +**Excluded paths are never opened**, at any depth, and the check runs on every +segment rather than only the last — `node_modules/pkg/index.js` is refused even +though `index.js` alone would be fine. Three lists, for three reasons +(`src/shared/workspace/exclusions.ts`): dependency and build directories +(listed but never descended into, so a tree does not silently misrepresent the +project); credential files (`.env*`, `.netrc`, `.npmrc`, `id_rsa`, `*.pem`, +`*.p12`, `secrets.json` — never listed as readable, never read, never +searched); and binary extensions, as a pre-filter ahead of the real test, which +is a NUL byte in the first bytes read plus a strict UTF-8 decode. + +**Everything is bounded**, and every bound reports itself rather than silently +shortening a result: tree depth (8), entries (2,000), entries per directory +(500), file size (512,000 bytes — **refused**, not truncated, for the same +reason an oversized audit record is rejected), search matches (200), files +opened by one search (1,000), and match excerpt length (240). A search query is +matched as a **literal substring, never compiled as a regular expression** — +compiling user input as a pattern is how a search box becomes a denial of +service against the process that owns every privileged operation here. + +**Nothing in this milestone can modify a file**, and that is structural rather +than a policy: there is no `workspace.write` action type, no write channel, no +preload function, and no request schema field capable of carrying content or a +destination. `main/workspace-inspector.ts` imports exactly `readdir`, +`readFile` and `stat` from `node:fs/promises`, and that import list is the +whole of its filesystem capability. A test snapshots a project before and after +a full listing, read, search and plan — including when every operation fails — +and asserts it is unchanged; another asserts the module logs nothing at all. + +**No path, name, query or file content reaches an audit record or an error.** +Audit parameters carry `{operation}` and at most a _length_ +(`{operation: 'search', queryLength: 6}`); a directory path can name a person, +a client or an unreleased product, and a search term is user content. +Filesystem errors are mapped by `code` alone — never `message`, which contains +the full path — into a twelve-code normalized vocabulary, and the sentence a +user reads is one of the renderer's own reviewed strings, never anything that +crossed the boundary. + +**The approved path is never persisted.** It lives in memory, per +`registerIpcHandlers` call, for one application run. Read access to someone's +source tree is a grant the user makes by clicking through a dialog, not a +preference restored silently on the next launch. + +**A coding plan is inert.** `codingPlanSchema` pins `diff` to `null` (declared, +not omitted, so a later edit that starts producing one must change the schema +and therefore be reviewed), `approvalRequired` to `true`, and `status` to +`'awaiting-approval'`; `changeType` admits only `'modify'` and `'review'`. The +plan is produced deterministically from observation, with **no model call** — +see [phase-2-coding-workspace.md](phase-2-coding-workspace.md) for why. Nothing +in this milestone consumes a plan, so approving one unlocks nothing; the gate +exists so whichever milestone adds a modification has one to route through. + +**Every workspace action is denied while the emergency stop is engaged.** None +of the three is on `EMERGENCY_STOP_EXEMPT_ACTION_TYPES`, and a test walks all +six channels to confirm it — including that the native picker is never even +shown, since `execute` never reaches `perform`. + ### Secret handling **[enforced by schema]** The settings schema is strict and declares no field @@ -652,7 +758,14 @@ exposes no way to address one. No generic pass-through channel exists. | `hasApiKey` reported as `true` for a provider that does not use a key (`none`, `ollama`) | Addressed, M7 — forced `false` regardless of the secret store; proven by test | | A denied or rejected secret operation still mutating the store or `hasApiKey` | Addressed, M7 — `perform` is structurally unreachable on `deny`/`reject`; `hasApiKey` is only refreshed after the store write already succeeded; proven by test | | Renderer-supplied `hasApiKey` reaching a persisted document | Addressed, M7 — `settingsUpdateRequestSchema` has no such field; `strictObject` rejects the extra key outright | -| Path traversal | Not reachable — all paths derive from the app-data directory; none is user-supplied | +| Path traversal | Addressed, Phase 2 M5 — user-supplied paths exist for the first time; contained lexically and again after `realpath`, both verified against a real filesystem | +| Symbolic link leading outside the approved project | Addressed, Phase 2 M5 — refused after canonicalisation; the tree walk keeps only regular files and real directories, so it cannot follow or loop on one | +| A credential file in the user's project being read or displayed | Addressed, Phase 2 M5 — `.env*`, key, keystore and credential-stemmed data files are never listed as readable, opened or searched, at any depth | +| The renderer choosing which directory becomes readable | Addressed, Phase 2 M5 — `workspace.select` takes no argument; the user chooses in a native dialog the main process owns, which is then still canonicalised and validated | +| The application's own data directory opened as a "project" | Addressed, Phase 2 M5 — refused in both directions, with both sides canonicalised first so a short (8.3) Windows ancestor cannot defeat the comparison | +| A file modified, created or deleted by the workspace | Addressed, Phase 2 M5 — structurally absent: no action type, channel, preload function or schema field can express one; proven by before/after snapshots across success and failure | +| A project path, file content or search query reaching the audit log | Addressed, Phase 2 M5 — parameters carry `{operation}` and at most a length; asserted by test | +| Unbounded memory or time from a hostile project tree | Addressed, Phase 2 M5 — depth, entry, per-directory, file-size, match and scanned-file caps, each reporting when it stopped; search is literal substring matching, never a compiled pattern | | Prompt injection, untrusted model or tool output | Reachable from Phase 2 M3 (a real provider replies) — no tool exists for a model to invoke, so untrusted assistant text still cannot authorize or perform an action; see the proposal/executor split | | Renderer or a compromised dependency making a network request directly | Addressed, Phase 2 M3 — CSP `connect-src 'none'` (unmodified since M2) plus the `src/shared` and `src/renderer/chat` purity/scan boundaries; only `src/main/openai-compatible-provider.ts` can reach the network | | Plaintext API key reaching the renderer through a real provider call | Addressed, Phase 2 M3 — stays inside `resolveMainChatProvider`/`openai-compatible-provider.ts`; never in `ActionProposal.parameters`, `chatSendResponseSchema`, an audit record, or a thrown error; proven by test | @@ -842,6 +955,65 @@ These are real and are stated plainly rather than described as solved. channels is in `tests/unit/main/ipc.test.ts`, against temporary directories, not the real application process. +20. **The workspace has a time-of-check/time-of-use gap.** Between `realpath` + returning and the subsequent `open`/`readdir`, a path component could in + principle be replaced with a symbolic link. Closing it properly needs + handle-based, `O_NOFOLLOW`-style APIs that Node does not expose portably. + The exposure is narrow — the attacker must already be able to write inside + the user's own approved project, on the user's own machine, between two + adjacent syscalls — and the consequence is bounded by everything else in + the layer: the result is still read-only, still size-capped, and still + refused if it is not text. Stated rather than described as solved. + +21. **The file viewer warns about bidirectional control characters; it does + not neutralise them.** A source file containing an override is shown + exactly as stored, with a prominent warning, because refusing would make + legitimate Arabic, Hebrew and Persian source unopenable and rewriting it + would mean the viewer lied about what is on disk. The text can therefore + still _render_ differently from how it is stored. File **names** and search + **excerpts** are handled more strictly — a name carrying one is refused + outright, and an excerpt is sanitised — because a name can disguise an + extension and a results list is where a reordered line would be most + convincing. + +22. **Credential detection in a project is name-based, like the audit log's + redaction, and inherits the same limitation.** `.env*`, known credential + file names, key and keystore extensions, and credential-stemmed _data_ + files are excluded. A credential pasted into an ordinary source file — + `src/config.ts` — is not caught, and the stem rule is deliberately skipped + for source and documentation extensions so that files _about_ credential + handling (this repository's own `src/main/secrets.ts`) stay readable. That + is the safe direction for an inspector whose job is to show a project + truthfully, but it is a real gap and is not closed by a value-level check. + +23. **A coding plan is keyword matching, not understanding.** It is derived + deterministically from bounded searches and the project's structure, with + no model call (see + [phase-2-coding-workspace.md](phase-2-coding-workspace.md) for why). It + will miss a file that is relevant without sharing vocabulary with the + request, and it says so in its own risks when nothing matched or when a + search was truncated. It is a grounded starting point, not an analysis. + +24. **The workspace bounds each operation, not the rate of operations.** One + listing, read or search is capped in every dimension, but nothing limits + how many a renderer may issue in succession. In a single-user desktop + application whose renderer is driven by the same person who approved the + project, this is acceptable; it would not be in a multi-user or remote + context, neither of which this project has. + +25. **No component-level render test for `Workspace.tsx`**, for the same reason + as limitation 18 and with the same mitigation: every state transition + (loading, empty, error, retry, staleness, the approval gate) lives in the + framework-independent `WorkspaceController` and is fully covered there. + What remains untested by an automated test is the JSX and event wiring + itself. The e2e suite likewise does not drive the six workspace channels + against the real built application — like `settings:*` and `secrets:*` + (limitation 19), they have real side effects against the developer's actual + `%APPDATA%\Local-Agent\`, and would additionally open a native dialog no + headless run can answer. Their behavioural coverage is in + `tests/unit/main/ipc.test.ts`, against temporary directories; the e2e suite + asserts only the bridge's static shape and the absence of any mutator. + --- ## Reporting diff --git a/src/main/directory-picker.ts b/src/main/directory-picker.ts new file mode 100644 index 0000000..142dc11 --- /dev/null +++ b/src/main/directory-picker.ts @@ -0,0 +1,53 @@ +/** + * The native project-directory picker (Phase 2, Milestone 5). + * + * The counterpart to `main/confirm.ts`, and native for exactly the same + * reason: this dialog is the *consent* for the whole workspace feature. It is + * the only place a directory outside the application's own data becomes + * readable, and the renderer must not be able to choose that directory, + * pre-fill it, forge the dialog, or dismiss it on the user's behalf. + * + * So the renderer supplies nothing. `workspaceSelectRequestSchema` takes no + * arguments at all, this function takes no path, and there is no default, + * suggested or remembered location: whatever `dialog.showOpenDialog` returns + * is what the user actually clicked on. A compromised renderer can ask that + * the user be asked, and nothing more. + * + * `properties` is deliberately minimal — `openDirectory` alone. In + * particular there is no `multiSelections` (one project at a time), + * no `createDirectory`/`promptToCreate` (this milestone writes nothing, and a + * picker that can create a folder writes), and no `showHiddenFiles`. + */ + +import type { BrowserWindow, OpenDialogOptions } from 'electron'; +import { dialog } from 'electron'; + +const DIALOG_OPTIONS: OpenDialogOptions = { + title: 'Local Agent — select a project to inspect', + buttonLabel: 'Approve project', + message: 'Local Agent will read files inside this directory. It cannot modify them.', + properties: ['openDirectory'], +}; + +/** + * Shows the picker and resolves to the chosen directory, or `null` when the + * user dismissed it. + * + * `null` is a first-class answer, not an error: cancelling a picker is the + * user declining, and the caller turns it into the dedicated + * `WORKSPACE_SELECTION_CANCELLED` code rather than a generic failure. + * + * The returned string is the raw OS path. It is *not* trusted as a project + * root on the strength of having come from here — `adoptProjectDirectory` + * still canonicalises it, confirms it is a directory, and refuses the + * application's own data directory. A dialog result is still input. + */ +export async function showDirectoryPicker(window: BrowserWindow | null): Promise { + const result = window + ? await dialog.showOpenDialog(window, DIALOG_OPTIONS) + : await dialog.showOpenDialog(DIALOG_OPTIONS); + + if (result.canceled) return null; + const [directory] = result.filePaths; + return directory === undefined || directory.length === 0 ? null : directory; +} diff --git a/src/main/index.ts b/src/main/index.ts index 54692c9..3a01b83 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, ipcMain, safeStorage, session } from 'electron'; import { join } from 'node:path'; import { showNativeConfirmation } from './confirm'; +import { showDirectoryPicker } from './directory-picker'; import { loadEmergencyState } from './emergency'; import { registerIpcHandlers } from './ipc'; import { resolveUserDataPaths } from './paths'; @@ -122,6 +123,12 @@ app safeStorage, requestConfirmation: (message) => showNativeConfirmation(window.isDestroyed() ? null : window, message), + // The coding workspace's one grant of read access (Phase 2, Milestone + // 5), and the only place a directory outside `%APPDATA%\Local-Agent` + // becomes readable. Built here, like the confirmation callback above, + // because this is the one file that imports the live `electron` module + // and holds the real window to parent the dialog to. + selectProjectDirectory: () => showDirectoryPicker(window.isDestroyed() ? null : window), nowFn: () => new Date().toISOString(), }); }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 2024fe2..35e1737 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -34,6 +34,14 @@ import { resolveMainChatProvider } from './chat-provider-registry'; import { ActionExecutionError } from './executor'; import type { UserDataPaths } from './paths'; import { clearSecret, SecretStoreUnavailableError, writeSecret } from './secrets'; +import { listProjectTree, readProjectFile, searchProject } from './workspace-inspector'; +import { createCodingPlan } from './workspace-planner'; +import { + adoptProjectDirectory, + createWorkspaceSession, + requireApprovedProject, + toProjectSummary, +} from './workspace-session'; import { readReconciledSettings, refreshHasApiKeyAfterSecretChange, @@ -70,14 +78,44 @@ import { settingsGetResponseSchema, settingsUpdateRequestSchema, settingsUpdateResponseSchema, + IPC_WORKSPACE_FILE_CHANNEL, + IPC_WORKSPACE_PLAN_CHANNEL, + IPC_WORKSPACE_SEARCH_CHANNEL, + IPC_WORKSPACE_SELECT_CHANNEL, + IPC_WORKSPACE_STATUS_CHANNEL, + IPC_WORKSPACE_TREE_CHANNEL, + workspaceFileRequestSchema, + workspaceFileResponseSchema, + workspacePlanRequestSchema, + workspacePlanResponseSchema, + workspaceSearchRequestSchema, + workspaceSearchResponseSchema, + workspaceSelectRequestSchema, + workspaceSelectResponseSchema, + workspaceStatusRequestSchema, + workspaceStatusResponseSchema, + workspaceTreeRequestSchema, + workspaceTreeResponseSchema, } from '../shared/schemas'; import type { ChatSendResponse, + CodingPlan, SecretsActionResponse, SecretStatusResult, SettingsActionResponse, Settings, + WorkspaceFile, + WorkspaceFileResponse, + WorkspacePlanResponse, + WorkspaceProjectResponse, + WorkspaceProjectSummary, + WorkspaceSearchResponse, + WorkspaceSearchResult, + WorkspaceTree, + WorkspaceTreeResponse, } from '../shared/schemas'; +import { isWorkspaceErrorCode, WorkspaceError } from '../shared/workspace/errors'; +import type { WorkspaceErrorCode } from '../shared/workspace/errors'; import type { ActionProposal, ActionResult, ActionType, ConfirmationResult } from '../shared/types'; /** `ActionResult.errorCode` values `secrets.write`'s `perform` may throw. */ @@ -114,6 +152,20 @@ export interface IpcHandlerRuntime { readonly requestConfirmation: (message: string) => Promise; /** UTC ISO-8601. Injected so this stays testable with a fixed clock. */ readonly nowFn: () => string; + /** + * Shows the native project-directory picker and resolves to what the user + * chose, or `null` if they dismissed it (Phase 2, Milestone 5). + * + * Injected for exactly the reason `requestConfirmation` is: the real + * implementation calls `electron`'s `dialog`, which exists only inside a + * running Electron process, so keeping the dependency at the boundary — + * built once in `main/index.ts` — is what lets this module's own tests run + * under plain Node while still exercising the real selection path. + * + * Note what this signature does *not* have: a parameter. Nothing the + * renderer sends can influence which directory is offered or chosen. + */ + readonly selectProjectDirectory: () => Promise; } /** @@ -205,6 +257,72 @@ function toChatSendResponse(result: ActionResult): ChatSendR }; } +/** + * Normalizes whatever code a workspace action failed with. + * + * `perform` below only ever throws a `WorkspaceError`, translated to an + * `ActionExecutionError` carrying the same code — but `execute` also has its + * own generic `EXECUTION_FAILED` for an unexpected throw, and that is not a + * member of the workspace vocabulary. Degrading it to `WORKSPACE_READ_FAILED` + * keeps a response schema-valid rather than letting an internal code fail + * validation on the way out, exactly as `toChatSendResponse` already does for + * the provider vocabulary. Defence in depth for a path that should not be + * reachable, not a substitute for `perform` normalizing its own failures. + */ +function toWorkspaceErrorCode(result: ActionResult): WorkspaceErrorCode | undefined { + if (result.errorCode === undefined) return undefined; + return isWorkspaceErrorCode(result.errorCode) ? result.errorCode : 'WORKSPACE_READ_FAILED'; +} + +function toWorkspaceProjectResponse( + result: ActionResult, +): WorkspaceProjectResponse { + const errorCode = toWorkspaceErrorCode(result); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { project: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + +function toWorkspaceTreeResponse(result: ActionResult): WorkspaceTreeResponse { + const errorCode = toWorkspaceErrorCode(result); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { tree: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + +function toWorkspaceFileResponse(result: ActionResult): WorkspaceFileResponse { + const errorCode = toWorkspaceErrorCode(result); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { file: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + +function toWorkspaceSearchResponse( + result: ActionResult, +): WorkspaceSearchResponse { + const errorCode = toWorkspaceErrorCode(result); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { results: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + +function toWorkspacePlanResponse(result: ActionResult): WorkspacePlanResponse { + const errorCode = toWorkspaceErrorCode(result); + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { plan: result.value }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + /** * `ipcMain` is passed in rather than imported here. * @@ -407,4 +525,154 @@ export function registerIpcHandlers(ipcMain: IpcMain, runtime: IpcHandlerRuntime inFlightChatRequests.get(requestId)?.abort(); return chatCancelResponseSchema.parse({ acknowledged: true }); }); + + // ------------------------------------------------------------------------- + // Coding workspace (Phase 2, Milestone 5) + // + // Six channels, all read-only, all routed through the same unmodified + // `runAction` -> `handleActionProposal` pipeline every other action-backed + // channel above uses. There is no seventh channel that writes. + // ------------------------------------------------------------------------- + + /** + * The project the user approved during *this* run. + * + * Created per `registerIpcHandlers` call, exactly like + * {@link inFlightChatRequests} above and for the same reason: one test's + * approved project can never leak into another's, and one application run's + * grant never survives into the next. Nothing persists it to disk. + */ + const workspaceSession = createWorkspaceSession(); + + /** + * Runs one workspace operation as a permission-gated, audited action. + * + * The `parameters` every caller passes below are deliberately thin — + * `operation`, and at most a *length*. No path, no project name, no query + * text and no file content ever enters an audit record: the milestone brief + * requires sensitive paths and contents to be kept out of logs, and this is + * the same discipline `chat.send` already applies by recording only + * `{provider, messageCount}` rather than the conversation. + * + * A thrown {@link WorkspaceError} becomes an `ActionExecutionError` carrying + * its normalized code, which is what `execute` already extracts into + * `ActionResult.errorCode` — the same translation `secrets.write` performs + * for `SecretStoreUnavailableError`. + */ + async function runWorkspaceAction( + actionType: ActionType, + parameters: Record, + perform: (now: string) => TValue | Promise, + ): Promise> { + const now = runtime.nowFn(); + const actionRuntime = buildActionRuntime(runtime, now); + + return runAction(actionRuntime, newProposal(actionType, parameters), null, async () => { + try { + return await perform(now); + } catch (error) { + if (error instanceof WorkspaceError) { + throw new ActionExecutionError(error.code, error.message); + } + throw error; + } + }); + } + + ipcMain.handle(IPC_WORKSPACE_STATUS_CHANNEL, async (_event, ...args: unknown[]) => { + workspaceStatusRequestSchema.parse(args); + + const result = await runWorkspaceAction( + 'workspace.read', + { operation: 'status' }, + () => { + const project = workspaceSession.get(); + return project === null ? null : toProjectSummary(project); + }, + ); + + return workspaceStatusResponseSchema.parse(toWorkspaceProjectResponse(result)); + }); + + ipcMain.handle(IPC_WORKSPACE_SELECT_CHANNEL, async (_event, ...args: unknown[]) => { + workspaceSelectRequestSchema.parse(args); + + const result = await runWorkspaceAction( + 'workspace.select', + { operation: 'select' }, + async (now) => { + // The renderer contributed nothing to this call. The user chooses in + // a native dialog the main process owns; a compromised renderer can + // ask that the question be put, and nothing more. + const chosenPath = await runtime.selectProjectDirectory(); + if (chosenPath === null) { + // Dismissing the picker is the user declining, so it gets its own + // code rather than a generic failure — the interface shows no + // error for it, and the audit trail still records honestly that + // the action did not complete. + throw new WorkspaceError('WORKSPACE_SELECTION_CANCELLED'); + } + + const project = await adoptProjectDirectory({ + chosenPath, + now, + userDataDir: runtime.userDataPaths.userDataDir, + }); + workspaceSession.set(project); + return toProjectSummary(project); + }, + ); + + return workspaceSelectResponseSchema.parse(toWorkspaceProjectResponse(result)); + }); + + ipcMain.handle(IPC_WORKSPACE_TREE_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ path }] = workspaceTreeRequestSchema.parse(args); + + const result = await runWorkspaceAction( + 'workspace.read', + { operation: 'tree' }, + () => listProjectTree(requireApprovedProject(workspaceSession), path), + ); + + return workspaceTreeResponseSchema.parse(toWorkspaceTreeResponse(result)); + }); + + ipcMain.handle(IPC_WORKSPACE_FILE_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ path }] = workspaceFileRequestSchema.parse(args); + + const result = await runWorkspaceAction( + 'workspace.read', + { operation: 'file' }, + () => readProjectFile(requireApprovedProject(workspaceSession), path), + ); + + return workspaceFileResponseSchema.parse(toWorkspaceFileResponse(result)); + }); + + ipcMain.handle(IPC_WORKSPACE_SEARCH_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ query, path }] = workspaceSearchRequestSchema.parse(args); + + const result = await runWorkspaceAction( + 'workspace.read', + // The query's *length*, never the query itself: a search term is user + // content, and user content does not belong in a security log. + { operation: 'search', queryLength: query.length }, + () => searchProject(requireApprovedProject(workspaceSession), query, path), + ); + + return workspaceSearchResponseSchema.parse(toWorkspaceSearchResponse(result)); + }); + + ipcMain.handle(IPC_WORKSPACE_PLAN_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ objective }] = workspacePlanRequestSchema.parse(args); + + const result = await runWorkspaceAction( + 'workspace.plan', + { operation: 'plan', objectiveLength: objective.length }, + (now) => createCodingPlan(requireApprovedProject(workspaceSession), objective, now), + ); + + return workspacePlanResponseSchema.parse(toWorkspacePlanResponse(result)); + }); } diff --git a/src/main/workspace-inspector.ts b/src/main/workspace-inspector.ts new file mode 100644 index 0000000..78ce867 --- /dev/null +++ b/src/main/workspace-inspector.ts @@ -0,0 +1,456 @@ +/** + * Read-only project inspection (Phase 2, Milestone 5). + * + * The only module in this codebase that reads a file the user chose, and it + * has three functions: list a tree, read one text file, search for a + * substring. There is no fourth. Nothing here opens a write handle, creates a + * directory, renames, deletes, or spawns anything — `node:fs/promises` is + * imported for exactly `readdir`, `readFile` and `stat`, and that import list + * is the whole of this module's filesystem capability. + * + * Every path reaching a syscall here came through + * `workspace-paths.ts`'s `resolveProjectPath`, so it is lexically safe, + * carries no excluded segment, and has been proven — after `realpath` — to + * live inside the approved root. Names discovered *by* the walk are held to + * the identical rule before they are ever returned, so a path the renderer + * receives is always one it can legally send back. + * + * Everything is bounded. A tree stops at a depth, an entry count and a + * per-directory count; a file is refused above a byte cap rather than + * truncated; a search stops at a match count and a scanned-file count. Each + * bound reports itself (`truncated`) rather than silently shortening a + * result, because a listing that quietly omits half a project is one a reader + * would draw the wrong conclusion from. + * + * **Nothing here logs.** No `console` call, no path in a thrown message: a + * file's contents and a user's directory layout are exactly the material the + * milestone brief says must not reach a log. + */ + +import { readdir, readFile, stat } from 'node:fs/promises'; + +import { joinAbsolute, resolveProjectPath, toWorkspaceError } from './workspace-paths'; +import type { ApprovedProject } from './workspace-session'; +import { + BIDI_CONTROL_PATTERN, + WORKSPACE_BINARY_SNIFF_BYTES, + WORKSPACE_MAX_DIRECTORY_ENTRIES, + WORKSPACE_MAX_FILE_BYTES, + WORKSPACE_MAX_FILE_CONTENT_LENGTH, + WORKSPACE_MAX_SEARCH_FILES, + WORKSPACE_MAX_SEARCH_RESULTS, + WORKSPACE_MAX_TREE_DEPTH, + WORKSPACE_MAX_TREE_ENTRIES, + WORKSPACE_SEARCH_EXCERPT_MAX_LENGTH, + WORKSPACE_SEARCH_MAX_FILE_BYTES, +} from '../shared/constants'; +import type { + WorkspaceEntry, + WorkspaceFile, + WorkspaceSearchMatch, + WorkspaceSearchResult, + WorkspaceTree, +} from '../shared/schemas/workspace.schema'; +import { WorkspaceError } from '../shared/workspace/errors'; +import { hasBinaryFileExtension, isExcludedEntryName } from '../shared/workspace/exclusions'; +import { isSafeWorkspaceRelativePath, joinWorkspacePath } from '../shared/workspace/path-safety'; + +/** One directory entry the walk decided to keep, before it was described. */ +interface WalkCandidate { + readonly name: string; + readonly kind: 'file' | 'directory'; + readonly absolutePath: string; + readonly relativePath: string; + readonly depth: number; + readonly excluded: boolean; +} + +/** + * Reads and orders one directory's usable children. + * + * Skips, without reporting an error: + * + * - anything that is neither a regular file nor a directory. This is what + * keeps symbolic links out of the walk entirely, and with them both + * directory loops (`a -> ..`) and escapes; sockets, FIFOs and device nodes + * go the same way, since none is something a code viewer should open. A + * symlinked file can still be opened *directly*, by path, where + * `resolveProjectPath` proves the link stays inside the project — the walk + * simply does not offer it. + * - any name this codebase's own path rules would refuse (a control + * character, a bidirectional override, a reserved device name, a trailing + * dot). Listing such a name would offer the renderer a path it could never + * legally send back. + */ +async function readDirectoryCandidates( + directoryAbsolute: string, + directoryRelative: string, + depth: number, +): Promise<{ readonly candidates: readonly WalkCandidate[]; readonly truncated: boolean }> { + const dirents = await readdir(directoryAbsolute, { withFileTypes: true }); + + const usable = dirents + .filter((dirent) => dirent.isFile() || dirent.isDirectory()) + .filter((dirent) => isSafeWorkspaceRelativePath(dirent.name)); + + usable.sort((a, b) => { + const aDir = a.isDirectory() ? 0 : 1; + const bDir = b.isDirectory() ? 0 : 1; + if (aDir !== bDir) return aDir - bDir; + return a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }); + }); + + const kept = usable.slice(0, WORKSPACE_MAX_DIRECTORY_ENTRIES); + const candidates = kept.map((dirent): WalkCandidate => { + const kind = dirent.isDirectory() ? ('directory' as const) : ('file' as const); + return { + name: dirent.name, + kind, + absolutePath: joinAbsolute(directoryAbsolute, dirent.name), + relativePath: joinWorkspacePath(directoryRelative, dirent.name), + depth, + excluded: isExcludedEntryName(dirent.name, kind), + }; + }); + + return { candidates, truncated: kept.length < usable.length }; +} + +/** A file's size in bytes, or `null` when it cannot be determined. */ +async function fileSize(absolutePath: string): Promise { + try { + const stats = await stat(absolutePath); + return stats.isFile() ? stats.size : null; + } catch { + return null; + } +} + +async function describeCandidate(candidate: WalkCandidate): Promise { + const base = { + path: candidate.relativePath, + name: candidate.name, + kind: candidate.kind, + depth: candidate.depth, + excluded: candidate.excluded, + }; + + if (candidate.kind === 'directory') { + return { ...base, readable: false }; + } + + const size = await fileSize(candidate.absolutePath); + const readable = + !candidate.excluded && + !hasBinaryFileExtension(candidate.name) && + size !== null && + size <= WORKSPACE_MAX_FILE_BYTES; + + return { ...base, ...(size === null ? {} : { size }), readable }; +} + +/** + * Lists a bounded, depth-first tree below one directory of the approved + * project. + * + * Depth-first (pre-order) rather than breadth-first so entries arrive in the + * order a tree is drawn — a directory immediately followed by its children — + * which is what lets the interface render a flat, indented list without + * rebuilding the hierarchy. + * + * An excluded directory is **listed and not descended into**. Hiding it would + * make the tree quietly disagree with what is on disk; the flag tells the + * reader why nothing is shown beneath it. + * + * A subdirectory that cannot be read (permissions, or removed mid-walk) is + * skipped and the walk continues — one unreadable directory must not fail the + * listing of an entire project. The *starting* directory is different: if + * that cannot be read, the caller asked for something it cannot have, and the + * error propagates. + */ +export async function listProjectTree( + project: ApprovedProject, + requestedPath: unknown, +): Promise { + const root = await resolveProjectPath(project.rootPath, requestedPath); + + let rootStats; + try { + rootStats = await stat(root.absolutePath); + } catch (error) { + throw toWorkspaceError(error); + } + if (!rootStats.isDirectory()) throw new WorkspaceError('WORKSPACE_UNSUPPORTED_ENTRY'); + + const entries: WorkspaceEntry[] = []; + let truncated = false; + + let initial; + try { + initial = await readDirectoryCandidates(root.absolutePath, root.relativePath, 0); + } catch (error) { + throw toWorkspaceError(error); + } + if (initial.truncated) truncated = true; + + // Reversed on push so the stack pops them back in sorted order. + const stack: WalkCandidate[] = [...initial.candidates].reverse(); + + while (stack.length > 0) { + if (entries.length >= WORKSPACE_MAX_TREE_ENTRIES) { + truncated = true; + break; + } + + const candidate = stack.pop(); + if (candidate === undefined) break; + + entries.push(await describeCandidate(candidate)); + + if (candidate.kind !== 'directory' || candidate.excluded) continue; + if (candidate.depth + 1 > WORKSPACE_MAX_TREE_DEPTH) { + truncated = true; + continue; + } + + try { + const child = await readDirectoryCandidates( + candidate.absolutePath, + candidate.relativePath, + candidate.depth + 1, + ); + if (child.truncated) truncated = true; + for (let index = child.candidates.length - 1; index >= 0; index -= 1) { + const next = child.candidates[index]; + if (next !== undefined) stack.push(next); + } + } catch { + // One unreadable subdirectory does not fail the whole listing. + truncated = true; + } + } + + return { root: root.relativePath, entries, truncated }; +} + +/** True when a NUL appears in the sampled prefix — the standard "not text" test. */ +function looksBinary(buffer: Buffer): boolean { + const sampleLength = Math.min(buffer.length, WORKSPACE_BINARY_SNIFF_BYTES); + for (let index = 0; index < sampleLength; index += 1) { + if (buffer[index] === 0) return true; + } + return false; +} + +/** + * Reads one text file from the approved project. + * + * Refusals, in the order they are checked — each one before any content + * exists in memory, except the last two, which need the bytes: + * + * 1. not a regular file → `WORKSPACE_UNSUPPORTED_ENTRY` + * 2. larger than the hard cap → `WORKSPACE_FILE_TOO_LARGE` + * 3. binary by extension → `WORKSPACE_BINARY_FILE` + * 4. NUL in the first bytes read → `WORKSPACE_BINARY_FILE` + * 5. not valid UTF-8 → `WORKSPACE_BINARY_FILE` + * + * Exclusion (a credential file, or anything under an excluded directory) was + * already refused by `resolveProjectPath`, at every segment of the path + * rather than only its last one. + * + * Oversized files are **refused, not truncated**: a partial source file + * invites a reader to conclude something from text that was silently cut off. + */ +export async function readProjectFile( + project: ApprovedProject, + requestedPath: unknown, +): Promise { + const target = await resolveProjectPath(project.rootPath, requestedPath); + const name = target.segments[target.segments.length - 1]; + if (name === undefined) throw new WorkspaceError('WORKSPACE_UNSUPPORTED_ENTRY'); + + let stats; + try { + stats = await stat(target.absolutePath); + } catch (error) { + throw toWorkspaceError(error); + } + if (!stats.isFile()) throw new WorkspaceError('WORKSPACE_UNSUPPORTED_ENTRY'); + if (stats.size > WORKSPACE_MAX_FILE_BYTES) { + throw new WorkspaceError('WORKSPACE_FILE_TOO_LARGE'); + } + if (hasBinaryFileExtension(name)) throw new WorkspaceError('WORKSPACE_BINARY_FILE'); + + let buffer: Buffer; + try { + buffer = await readFile(target.absolutePath); + } catch (error) { + throw toWorkspaceError(error); + } + + // Re-checked against what was actually read, not only against the size + // `stat` reported a moment earlier. + if (buffer.length > WORKSPACE_MAX_FILE_BYTES) { + throw new WorkspaceError('WORKSPACE_FILE_TOO_LARGE'); + } + if (looksBinary(buffer)) throw new WorkspaceError('WORKSPACE_BINARY_FILE'); + + let content: string; + try { + // `fatal: true` is the point: a file that is not valid UTF-8 must be + // refused, not silently rendered full of replacement characters as though + // it had been read correctly. + content = new TextDecoder('utf-8', { fatal: true }).decode(buffer); + } catch { + throw new WorkspaceError('WORKSPACE_BINARY_FILE'); + } + + if (content.length > WORKSPACE_MAX_FILE_CONTENT_LENGTH) { + throw new WorkspaceError('WORKSPACE_FILE_TOO_LARGE'); + } + + const warnings = BIDI_CONTROL_PATTERN.test(content) + ? (['bidirectional-control-characters'] as const) + : ([] as const); + + return { + metadata: { + path: target.relativePath, + name, + size: buffer.length, + lineCount: content.length === 0 ? 0 : content.split('\n').length, + encoding: 'utf-8', + warnings: [...warnings], + }, + content, + }; +} + +/** + * Makes one line safe to show in a results list. + * + * Control characters become spaces and bidirectional overrides are dropped + * outright. This is the one place in the workspace where text is altered + * rather than shown as stored, and the asymmetry is deliberate: an excerpt is + * already a lossy fragment, and a results list is exactly where a line + * reordered by a bidi override would be most convincing and least likely to + * be double-checked. Whole-file content is never rewritten this way — it is + * shown verbatim, with a warning. + * + * Written as a character walk rather than a regular expression so no control + * character has to appear in this file's own source. + */ +function sanitizeExcerpt(line: string): string { + let out = ''; + for (const character of line) { + const code = character.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) { + out += ' '; + continue; + } + if (code >= 0x202a && code <= 0x202e) continue; + if (code >= 0x2066 && code <= 0x2069) continue; + out += character; + } + return out; +} + +/** A bounded window of the line, centred on the match. */ +function buildExcerpt(line: string, matchIndex: number): string { + const sanitized = sanitizeExcerpt(line); + if (sanitized.length <= WORKSPACE_SEARCH_EXCERPT_MAX_LENGTH) return sanitized.trim(); + const start = Math.max(0, matchIndex - Math.floor(WORKSPACE_SEARCH_EXCERPT_MAX_LENGTH / 3)); + return sanitized.slice(start, start + WORKSPACE_SEARCH_EXCERPT_MAX_LENGTH).trim(); +} + +/** Every file the search may open, in tree order, already bounded. */ +async function collectSearchableFiles( + project: ApprovedProject, + requestedPath: unknown, +): Promise<{ readonly files: readonly WorkspaceEntry[]; readonly truncated: boolean }> { + const tree = await listProjectTree(project, requestedPath); + const files = tree.entries.filter((entry) => entry.kind === 'file' && entry.readable); + return { files, truncated: tree.truncated }; +} + +/** + * Searches for a literal substring inside the approved project. + * + * **Never a regular expression.** The query comes from a text field, and + * compiling user input as a pattern is how a search box becomes a denial of + * service against the process that owns every privileged operation in this + * application. Matching is a case-insensitive `indexOf`, and nothing else. + * + * At most one match is reported per line: a single minified line can contain + * hundreds of occurrences, which would fill the whole result budget from one + * file and tell the reader nothing they could act on. + */ +export async function searchProject( + project: ApprovedProject, + query: string, + requestedPath: unknown, +): Promise { + const { files, truncated: treeTruncated } = await collectSearchableFiles(project, requestedPath); + + const needle = query.toLowerCase(); + const matches: WorkspaceSearchMatch[] = []; + let filesScanned = 0; + let truncated = treeTruncated; + + for (const file of files) { + if (matches.length >= WORKSPACE_MAX_SEARCH_RESULTS) { + truncated = true; + break; + } + if (filesScanned >= WORKSPACE_MAX_SEARCH_FILES) { + truncated = true; + break; + } + if (file.size === undefined || file.size > WORKSPACE_SEARCH_MAX_FILE_BYTES) continue; + + let target; + try { + target = await resolveProjectPath(project.rootPath, file.path); + } catch { + // A file that vanished, or that turned out not to resolve inside the + // project after all, is skipped — never searched on the strength of + // having appeared in a listing a moment ago. + continue; + } + + let buffer: Buffer; + try { + buffer = await readFile(target.absolutePath); + } catch { + continue; + } + filesScanned += 1; + if (looksBinary(buffer)) continue; + + let content: string; + try { + content = new TextDecoder('utf-8', { fatal: true }).decode(buffer); + } catch { + continue; + } + + const lines = content.split('\n'); + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + if (matches.length >= WORKSPACE_MAX_SEARCH_RESULTS) { + truncated = true; + break; + } + const line = lines[lineIndex] ?? ''; + const column = line.toLowerCase().indexOf(needle); + if (column === -1) continue; + matches.push({ + path: file.path, + line: lineIndex + 1, + column: column + 1, + excerpt: buildExcerpt(line, column), + }); + } + } + + return { query, matches, filesScanned, truncated }; +} diff --git a/src/main/workspace-paths.ts b/src/main/workspace-paths.ts new file mode 100644 index 0000000..c117f16 --- /dev/null +++ b/src/main/workspace-paths.ts @@ -0,0 +1,153 @@ +/** + * Path containment for the read-only workspace (Phase 2, Milestone 5). + * + * This is the **second** of the two containment defences, and the one that + * touches the disk. `src/shared/workspace/path-safety.ts` decides whether a + * string is even shaped like a path inside a project; this module decides + * where that string actually lands once it is joined onto a real root and the + * filesystem has had its say. + * + * Both are required, and neither is a duplicate of the other: + * + * - A lexical rule cannot see a symbolic link. `src/notes` may be a perfectly + * well-formed relative path and still be a link to `C:\Users\me\.ssh`. + * - A `realpath` check cannot run on a string that has not been joined onto a + * root yet — and joining is exactly the step an absolute path or a `..` + * segment subverts, so by the time `realpath` could look, the damage would + * already be in the path it was handed. + * + * So every resolution runs, in order: validate lexically, refuse excluded + * segments, join, check containment on the joined path, canonicalise with + * `realpath`, and check containment **again** on the canonical result. A link + * pointing outside the project fails the last check even though it passed the + * first. + * + * ## The one gap, stated plainly + * + * Between `realpath` returning and the subsequent `open`/`readdir`, a path + * component could in principle be replaced with a link — the classic + * time-of-check/time-of-use race. Closing it properly needs handle-based, + * `O_NOFOLLOW`-style APIs that Node does not expose portably. The exposure + * here is narrow (the attacker must already be able to write inside the + * user's own approved project, on the user's own machine, between two + * adjacent syscalls) and the consequence is bounded by everything else in + * this layer: the result is still read-only, still size-capped, and still + * refused if it is not text. It is recorded in `docs/security-model.md` + * rather than described as solved. + */ + +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { realpath } from 'node:fs/promises'; + +import { WorkspaceError } from '../shared/workspace/errors'; +import { containsExcludedSegment } from '../shared/workspace/exclusions'; +import { normalizeWorkspaceRelativePath } from '../shared/workspace/path-safety'; + +/** + * True when `candidate` is `root` itself or lies beneath it. + * + * Uses `path.relative` rather than a string prefix comparison, which matters + * on Windows for two reasons a prefix test gets wrong: `relative` compares + * case-insensitively there (so `C:\Foo` contains `C:\foo\bar`), and it + * normalises mixed separators. The explicit `'..'` checks are written against + * a whole segment rather than as `startsWith('..')`, so a directory + * legitimately named `..config` is not mistaken for an escape. + */ +export function isContainedPath(root: string, candidate: string): boolean { + const relativePath = relative(root, candidate); + if (relativePath === '') return true; + if (relativePath === '..' || relativePath.startsWith(`..${sep}`)) return false; + // An absolute result means the two share no common root at all — different + // drives on Windows, for instance. + return !isAbsolute(relativePath); +} + +/** One resolved, contained entry inside an approved project. */ +export interface ResolvedProjectPath { + /** The canonical absolute path, after `realpath`. Safe to open. */ + readonly absolutePath: string; + /** The canonical project-relative path; `''` for the project root. */ + readonly relativePath: string; + readonly segments: readonly string[]; +} + +/** + * Maps a filesystem error to the workspace's normalized vocabulary. + * + * Reads only `code`, never `message` — an `errno` message contains the full + * path, which must not reach a thrown error that might later be logged or + * shown. Anything unrecognised becomes the generic read failure rather than + * being passed through. + */ +export function toWorkspaceError(error: unknown): WorkspaceError { + if (error instanceof WorkspaceError) return error; + const code = (error as { code?: unknown } | null)?.code; + if (code === 'ENOENT' || code === 'ENOTDIR') return new WorkspaceError('WORKSPACE_NOT_FOUND'); + if (code === 'EACCES' || code === 'EPERM') return new WorkspaceError('WORKSPACE_ACCESS_DENIED'); + if (code === 'EISDIR' || code === 'ELOOP' || code === 'ENAMETOOLONG') { + return new WorkspaceError('WORKSPACE_UNSUPPORTED_ENTRY'); + } + return new WorkspaceError('WORKSPACE_READ_FAILED'); +} + +/** + * Resolves one renderer-supplied relative path against an approved project + * root, or throws a {@link WorkspaceError}. + * + * `rootPath` must already be canonical — `adoptProjectDirectory` runs + * `realpath` on it once, at selection time, so this function compares two + * canonical paths rather than one canonical and one not. + */ +export async function resolveProjectPath( + rootPath: string, + requestedPath: unknown, +): Promise { + const normalized = normalizeWorkspaceRelativePath(requestedPath); + if (!normalized.ok) throw new WorkspaceError('WORKSPACE_INVALID_PATH'); + + if (containsExcludedSegment(normalized.segments)) { + throw new WorkspaceError('WORKSPACE_PATH_EXCLUDED'); + } + + const joined = + normalized.segments.length === 0 + ? resolve(rootPath) + : resolve(rootPath, ...normalized.segments); + + // Cannot fail given the lexical rules above; kept because "cannot fail" is + // a claim about today's rules, and this is the check that would catch it if + // one of them were ever loosened. + if (!isContainedPath(rootPath, joined)) { + throw new WorkspaceError('WORKSPACE_PATH_OUTSIDE_PROJECT'); + } + + let canonical: string; + try { + canonical = await realpath(joined); + } catch (error) { + throw toWorkspaceError(error); + } + + // The check that actually catches a symbolic link: `joined` was inside the + // project by construction, `canonical` is where it really points. + if (!isContainedPath(rootPath, canonical)) { + throw new WorkspaceError('WORKSPACE_PATH_OUTSIDE_PROJECT'); + } + + return { + absolutePath: canonical, + relativePath: normalized.path, + segments: normalized.segments, + }; +} + +/** + * Joins a directory's absolute path and one child name. + * + * A thin wrapper so the walk never builds an absolute path by string + * concatenation, which is where separator and normalisation mistakes come + * from. + */ +export function joinAbsolute(directory: string, name: string): string { + return join(directory, name); +} diff --git a/src/main/workspace-planner.ts b/src/main/workspace-planner.ts new file mode 100644 index 0000000..a471398 --- /dev/null +++ b/src/main/workspace-planner.ts @@ -0,0 +1,168 @@ +/** + * The coding-plan inspection layer (Phase 2, Milestone 5). + * + * Gathers what can be observed about an approved project — which files + * mention the objective's terms, which project markers exist, whether there + * is test tooling, whether the search saw everything — and hands those + * observations to the pure builder in `src/shared/workspace/plan.ts`. + * + * The split matches the one this codebase already uses for every decision + * that matters: a pure core (`decidePermission`, `buildCodingPlan`) behind a + * thin layer that does the I/O. Everything about *what a plan says* is + * deterministic and testable without a filesystem; everything about *what is + * true of this project* is measured here, once, under the same bounds every + * other workspace read obeys. + * + * This module performs no write, calls no model, and produces nothing that + * can be applied. A plan is a document: `codingPlanSchema` pins its `diff` to + * `null` and its `status` to `'awaiting-approval'`, and there is no function + * anywhere in this milestone that consumes one. + */ + +import { listProjectTree, searchProject } from './workspace-inspector'; +import type { ApprovedProject } from './workspace-session'; +import { WORKSPACE_PLAN_MAX_FILES, WORKSPACE_PLAN_MAX_SEARCH_TERMS } from '../shared/constants'; +import type { CodingPlan } from '../shared/schemas/workspace.schema'; +import { buildCodingPlan, extractObjectiveKeywords } from '../shared/workspace/plan'; +import type { CodingPlanObservations, ObservedFile } from '../shared/workspace/plan'; + +/** + * Runs the bounded searches that identify candidate files. + * + * At most {@link WORKSPACE_PLAN_MAX_SEARCH_TERMS} terms are searched, not + * every keyword: each search opens up to `WORKSPACE_MAX_SEARCH_FILES` files, + * so an unbounded number of terms would multiply into an unbounded amount of + * reading. The terms are already ordered longest-first by + * `extractObjectiveKeywords`, and a longer term is the more specific one, so + * truncating the list keeps the searches that discriminate best. + * + * A file's score is the number of *distinct terms* that matched it, not the + * number of matches: a file mentioning three of the objective's terms once + * each is a better candidate than one mentioning a single term thirty times. + */ +async function searchForKeywords( + project: ApprovedProject, + keywords: readonly string[], +): Promise<{ + readonly scores: ReadonlyMap; + readonly filesInspected: number; + readonly truncated: boolean; +}> { + const scores = new Map(); + let filesInspected = 0; + let truncated = false; + + for (const keyword of keywords.slice(0, WORKSPACE_PLAN_MAX_SEARCH_TERMS)) { + const result = await searchProject(project, keyword, ''); + filesInspected += result.filesScanned; + if (result.truncated) truncated = true; + + const seen = new Set(); + for (const match of result.matches) { + if (seen.has(match.path)) continue; + seen.add(match.path); + scores.set(match.path, (scores.get(match.path) ?? 0) + 1); + } + } + + return { scores, filesInspected, truncated }; +} + +/** + * Adds files whose *path* mentions a keyword, even when their contents do + * not. + * + * A request about "onboarding" should surface `src/renderer/Onboarding.tsx` + * whether or not the word appears inside it. These score below a content + * match, which is what the fractional weight expresses — it keeps a + * path-only match from outranking a file that genuinely discusses the term. + */ +function scorePathMatches( + paths: readonly string[], + keywords: readonly string[], + scores: Map, +): void { + if (keywords.length === 0) return; + for (const path of paths) { + const lowered = path.toLowerCase(); + const hits = keywords.filter((keyword) => lowered.includes(keyword)).length; + if (hits > 0) scores.set(path, (scores.get(path) ?? 0) + hits * 0.5); + } +} + +/** + * Scores files whose path mentions a keyword, from one bounded listing of the + * project root, and reports whether that listing was complete. + * + * Returns `true` — "the picture is partial" — for both a truncated listing + * and a root that could not be listed at all. A project whose root is + * unreadable still produces a plan, one that says so through the builder's + * own observed risks; failing the whole request here would be less useful and + * no safer, since nothing about a plan can act on anything. + * + * Written to return the flag rather than assign an outer `let`: a variable + * that is only ever written inside a `try` is exactly the shape a lint + * auto-fix will "simplify", and simplifying control flow it has misread is + * how a behaviour change slips in unreviewed. + */ +async function scoreProjectPaths( + project: ApprovedProject, + keywords: readonly string[], + scores: Map, +): Promise { + try { + const tree = await listProjectTree(project, ''); + const readablePaths = tree.entries + .filter((entry) => entry.kind === 'file' && entry.readable) + .map((entry) => entry.path); + scorePathMatches(readablePaths, keywords, scores); + return tree.truncated; + } catch { + return true; + } +} + +/** Everything the plan builder needs, measured from the real project. */ +export async function gatherPlanObservations( + project: ApprovedProject, + objective: string, +): Promise { + const keywords = extractObjectiveKeywords(objective); + const { scores, filesInspected, truncated } = await searchForKeywords(project, keywords); + + const mutableScores = new Map(scores); + const treeTruncated = await scoreProjectPaths(project, keywords, mutableScores); + + const files: ObservedFile[] = [...mutableScores.entries()] + .map(([path, score]) => ({ path, matchCount: Math.round(score) })) + .sort((a, b) => + b.matchCount === a.matchCount ? a.path.localeCompare(b.path) : b.matchCount - a.matchCount, + ) + .slice(0, WORKSPACE_PLAN_MAX_FILES); + + return { + projectName: project.name, + markers: project.markers, + hasGitMetadata: project.hasGitMetadata, + hasTestTooling: project.hasTestTooling, + files, + filesInspected, + searchTruncated: truncated || treeTruncated, + }; +} + +/** + * Produces one inert coding plan for an approved project. + * + * `now` is supplied by the caller, following the convention every other + * module here follows: nothing in the workspace layer reads the clock itself, + * so a test's plan is byte-for-byte reproducible. + */ +export async function createCodingPlan( + project: ApprovedProject, + objective: string, + now: string, +): Promise { + const observations = await gatherPlanObservations(project, objective); + return buildCodingPlan({ objective, observations, generatedAt: now }); +} diff --git a/src/main/workspace-session.ts b/src/main/workspace-session.ts new file mode 100644 index 0000000..e1141eb --- /dev/null +++ b/src/main/workspace-session.ts @@ -0,0 +1,236 @@ +/** + * The approved-project session (Phase 2, Milestone 5). + * + * Holds exactly one thing: which directory the user approved in the native + * picker during *this* run of the application. + * + * Three properties, each deliberate: + * + * - **In memory only.** Nothing here is persisted. The approved path is not + * written to `settings.json`, not stored in the secret store, and not + * remembered across restarts. Read access to someone's source tree is not + * a preference to be restored silently on the next launch; it is a grant + * the user makes, per session, by clicking through a dialog. The milestone + * brief says "store only the approved project path in the current + * session", and this is that, literally. + * - **One per `registerIpcHandlers` call**, exactly like `main/ipc.ts`'s + * in-flight chat request map — never a module-level singleton, so one + * test's approved project can never leak into another's. + * - **Nothing outside this module can set it.** The only way in is + * {@link adoptProjectDirectory}, which canonicalises the path, confirms it + * is a directory, and refuses the application's own data directory. + */ + +import { basename, dirname, join } from 'node:path'; +import { realpath, stat } from 'node:fs/promises'; + +import { isContainedPath, toWorkspaceError } from './workspace-paths'; +import { workspaceProjectSummarySchema } from '../shared/schemas/workspace.schema'; +import type { WorkspaceProjectSummary } from '../shared/schemas/workspace.schema'; +import { WorkspaceError } from '../shared/workspace/errors'; +import { + WORKSPACE_PROJECT_MARKER_FILES, + WORKSPACE_TEST_MARKER_FILES, +} from '../shared/workspace/exclusions'; +import { joinAbsolute } from './workspace-paths'; + +/** One approved project, as the main process holds it. */ +export interface ApprovedProject { + /** The canonical root. Every path resolution is relative to this. */ + readonly rootPath: string; + readonly name: string; + readonly selectedAt: string; + /** Which of {@link WORKSPACE_PROJECT_MARKER_FILES} exist at the root. */ + readonly markers: readonly string[]; + readonly hasGitMetadata: boolean; + /** True when a marker implying an automated test setup was found. */ + readonly hasTestTooling: boolean; +} + +export interface WorkspaceSession { + /** The approved project, or `null` when the user has not chosen one. */ + get(): ApprovedProject | null; + set(project: ApprovedProject): void; + clear(): void; +} + +export function createWorkspaceSession(): WorkspaceSession { + let approved: ApprovedProject | null = null; + return { + get: () => approved, + set: (project) => { + approved = project; + }, + clear: () => { + approved = null; + }, + }; +} + +/** + * Returns the approved project, or throws the normalized "no project" error. + * + * Every workspace operation except selection itself starts here, so there is + * one place — not five — where "the renderer asked to read something before + * approving anything" is decided. + */ +export function requireApprovedProject(session: WorkspaceSession): ApprovedProject { + const project = session.get(); + if (project === null) throw new WorkspaceError('WORKSPACE_NO_PROJECT'); + return project; +} + +/** True when the entry exists, whatever it is. Never reads it. */ +async function entryExists(absolutePath: string): Promise { + try { + await stat(absolutePath); + return true; + } catch { + return false; + } +} + +/** + * Detects which project markers exist at the root. + * + * Presence only — not one of these files is opened. `.git` is detected the + * same way, which is the whole of this milestone's "Git metadata" support: + * the interface learns that a project is version-controlled without anything + * listing or reading a single byte inside `.git`, which stays on the excluded + * list for every other purpose. + */ +async function detectMarkers(rootPath: string): Promise { + const found: string[] = []; + for (const marker of WORKSPACE_PROJECT_MARKER_FILES) { + if (await entryExists(joinAbsolute(rootPath, marker))) found.push(marker); + } + return found; +} + +/** + * Canonicalises a path that may not exist yet. + * + * `%APPDATA%\Local-Agent` is created on first write, so on a clean install it + * genuinely may not be there — but the comparison it is used for still has to + * work. When the directory itself cannot be resolved, its parent is + * canonicalised and the final segment re-appended, which is enough because + * `%APPDATA%` always exists. If even that fails, the raw path is returned: + * a comparison against an uncanonical path is weaker than one against a + * canonical path, but it is never *wrong* in the permissive direction for the + * ordinary case where no short name is involved. + */ +async function canonicalizeExistingOrParent(path: string): Promise { + try { + return await realpath(path); + } catch { + try { + return join(await realpath(dirname(path)), basename(path)); + } catch { + return path; + } + } +} + +export interface AdoptProjectOptions { + /** The raw path the native picker returned. Still treated as input. */ + readonly chosenPath: string; + /** UTC ISO-8601, supplied by the caller. This module reads no clock. */ + readonly now: string; + /** + * The application's own user-data directory, refused as a project root. + * + * Opening `%APPDATA%\Local-Agent` as a "project" would point the inspector + * at the settings file, the permission policy and the audit log. The + * encrypted secret store would still be refused by name + * (`isCredentialFileName` matches the `secrets` stem), and everything would + * still be read-only — but the application's own state is categorically not + * a coding project, and refusing it outright is both cheaper and clearer + * than reasoning about which parts of it happen to be safe. + */ + readonly userDataDir: string; +} + +/** + * Turns a picker result into an approved project, or throws a + * {@link WorkspaceError}. + * + * The dialog result is *not* trusted merely because a native dialog produced + * it. It is canonicalised, confirmed to be a directory, checked against the + * application's own data directory in both directions, and finally validated + * as a {@link WorkspaceProjectSummary} — so a directory whose own name + * carries a control character or a bidirectional override is refused here, + * before it can be displayed or become a root that later paths are joined to. + */ +export async function adoptProjectDirectory( + options: AdoptProjectOptions, +): Promise { + const { chosenPath, now, userDataDir } = options; + + let rootPath: string; + try { + rootPath = await realpath(chosenPath); + } catch (error) { + // A picker result that no longer resolves is an unusable project, not a + // missing file the caller asked for by name. + void toWorkspaceError(error); + throw new WorkspaceError('WORKSPACE_INVALID_PROJECT'); + } + + let isDirectory: boolean; + try { + isDirectory = (await stat(rootPath)).isDirectory(); + } catch { + throw new WorkspaceError('WORKSPACE_INVALID_PROJECT'); + } + if (!isDirectory) throw new WorkspaceError('WORKSPACE_INVALID_PROJECT'); + + // Both directions: the chosen root must not be inside the application's + // data directory, and must not contain it either (choosing `%APPDATA%` + // itself, or a drive root, would otherwise pull it in). + // + // Both sides must be canonical for the comparison to mean anything. + // `rootPath` already is; `userDataDir` arrives as `main/paths.ts` built it, + // from whatever `app.getPath('appData')` returned, which on Windows can + // carry a short (8.3) ancestor such as `C:\Users\VUNHAT~1\…`. Comparing a + // short form against a long one finds no common root at all, and the guard + // would silently pass — so it is canonicalised here first. + const canonicalUserDataDir = await canonicalizeExistingOrParent(userDataDir); + if ( + isContainedPath(canonicalUserDataDir, rootPath) || + isContainedPath(rootPath, canonicalUserDataDir) + ) { + throw new WorkspaceError('WORKSPACE_INVALID_PROJECT'); + } + + const name = basename(rootPath); + const markers = await detectMarkers(rootPath); + + const project: ApprovedProject = { + rootPath, + name, + selectedAt: now, + markers, + hasGitMetadata: markers.includes('.git'), + hasTestTooling: markers.some((marker) => WORKSPACE_TEST_MARKER_FILES.includes(marker)), + }; + + // Refuses a root that could not be described safely — an empty name (a + // drive root such as `C:\`), or a name or path carrying a control character + // or a bidirectional override. + if (!workspaceProjectSummarySchema.safeParse(toProjectSummary(project)).success) { + throw new WorkspaceError('WORKSPACE_INVALID_PROJECT'); + } + + return project; +} + +/** The renderer-facing view of an approved project. */ +export function toProjectSummary(project: ApprovedProject): WorkspaceProjectSummary { + return { + name: project.name, + path: project.rootPath, + selectedAt: project.selectedAt, + markers: [...project.markers], + hasGitMetadata: project.hasGitMetadata, + }; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 27e31a8..aae1bb2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -19,6 +19,18 @@ import { secretsWriteResponseSchema, settingsGetResponseSchema, settingsUpdateResponseSchema, + IPC_WORKSPACE_FILE_CHANNEL, + IPC_WORKSPACE_PLAN_CHANNEL, + IPC_WORKSPACE_SEARCH_CHANNEL, + IPC_WORKSPACE_SELECT_CHANNEL, + IPC_WORKSPACE_STATUS_CHANNEL, + IPC_WORKSPACE_TREE_CHANNEL, + workspaceFileResponseSchema, + workspacePlanResponseSchema, + workspaceSearchResponseSchema, + workspaceSelectResponseSchema, + workspaceStatusResponseSchema, + workspaceTreeResponseSchema, type ChatChunkEvent, type ChatMessage, type ChatSendResponse, @@ -26,6 +38,11 @@ import { type SecretsActionResponse, type SettingsActionResponse, type SettingsUpdateInput, + type WorkspaceFileResponse, + type WorkspacePlanResponse, + type WorkspaceProjectResponse, + type WorkspaceSearchResponse, + type WorkspaceTreeResponse, } from '../shared/schemas'; /** @@ -143,6 +160,51 @@ const bridge = { }; }, }, + /** + * The read-only coding workspace (Phase 2, Milestone 5). + * + * Six functions, six fixed channels, and — the property worth checking + * first — **no way to name a directory**. `select` takes no argument at + * all: the main process opens a native picker and the user chooses, so + * nothing on this bridge can point the application at a directory of the + * caller's choosing. Every other function takes a path *relative* to + * whatever the user already approved, which the main process validates for + * containment before it touches the filesystem and re-validates after + * resolving it. + * + * There is no `write`, no `create`, no `delete` and no `apply`. The absence + * is the control: a renderer cannot ask for a modification because no + * function here expresses one. + */ + workspace: { + status: async (): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKSPACE_STATUS_CHANNEL); + return workspaceStatusResponseSchema.parse(result); + }, + select: async (): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKSPACE_SELECT_CHANNEL); + return workspaceSelectResponseSchema.parse(result); + }, + tree: async (path: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKSPACE_TREE_CHANNEL, { path }); + return workspaceTreeResponseSchema.parse(result); + }, + file: async (path: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKSPACE_FILE_CHANNEL, { path }); + return workspaceFileResponseSchema.parse(result); + }, + search: async (query: string, path: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKSPACE_SEARCH_CHANNEL, { + query, + path, + }); + return workspaceSearchResponseSchema.parse(result); + }, + plan: async (objective: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_WORKSPACE_PLAN_CHANNEL, { objective }); + return workspacePlanResponseSchema.parse(result); + }, + }, }; contextBridge.exposeInMainWorld('localAgent', bridge); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4d4eb02..d6d6f4a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2,10 +2,13 @@ import { useEffect, useState } from 'react'; import { Chat } from './chat/Chat'; import { Onboarding } from './Onboarding'; +import { Workspace } from './workspace/Workspace'; import { createDefaultSettings } from '../shared/schemas'; import type { Settings } from '../shared/schemas'; type Phase = 'loading' | 'onboarding' | 'ready'; +/** Which surface the shell is showing (Phase 2, Milestone 5). */ +type View = 'chat' | 'workspace'; /** * Used only when `settings.get` itself did not succeed (denied, or the @@ -22,6 +25,7 @@ export function App() { const [phase, setPhase] = useState('loading'); const [settings, setSettings] = useState(null); const [health, setHealth] = useState('checking'); + const [view, setView] = useState('chat'); useEffect(() => { void window.localAgent.health().then((result) => { @@ -77,8 +81,37 @@ export function App() {

Welcome back, {settings.user.displayName}.

)}

Main process: {health}

+ {/* + Two surfaces, one shell (Phase 2, Milestone 5). A plain view switch + rather than a redesign: the chat surface is exactly what it was, and + the workspace is a sibling next to it. + */} + - + {view === 'chat' ? ( + + ) : ( + + )} ); } diff --git a/src/renderer/local-agent-bridge.d.ts b/src/renderer/local-agent-bridge.d.ts index 3bccade..4db868d 100644 --- a/src/renderer/local-agent-bridge.d.ts +++ b/src/renderer/local-agent-bridge.d.ts @@ -16,6 +16,11 @@ import type { SecretsActionResponse, SettingsActionResponse, SettingsUpdateInput, + WorkspaceFileResponse, + WorkspacePlanResponse, + WorkspaceProjectResponse, + WorkspaceSearchResponse, + WorkspaceTreeResponse, } from '../shared/schemas'; export {}; @@ -42,6 +47,19 @@ declare global { /** Subscribe to streaming previews; returns an unsubscribe function. */ readonly onChunk: (listener: (event: ChatChunkEvent) => void) => () => void; }; + /** + * The read-only coding workspace (Phase 2, Milestone 5). Note that + * `select` takes no argument: the directory is chosen by the user in a + * native picker the main process owns, never named by the renderer. + */ + readonly workspace: { + readonly status: () => Promise; + readonly select: () => Promise; + readonly tree: (path: string) => Promise; + readonly file: (path: string) => Promise; + readonly search: (query: string, path: string) => Promise; + readonly plan: (objective: string) => Promise; + }; }; } } diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 5555674..b855c82 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -233,3 +233,349 @@ main.app-ready { .chat__composer textarea:disabled { opacity: 0.6; } + +/* --------------------------------------------------------------------- */ +/* Coding workspace (Phase 2, Milestone 5) */ +/* */ +/* Reuses the existing palette and shapes rather than introducing a */ +/* second visual language: the same #2a3750 borders, #0c1626 panels and */ +/* #73d5c8 accent the chat surface already uses. */ +/* --------------------------------------------------------------------- */ + +.app-views { + display: flex; + gap: 0.5rem; + margin-top: 0.6rem; +} +.app-views button { + background: transparent; + border: 1px solid #2a3750; + color: #aebbd0; + font-size: 0.85rem; + padding: 0.35rem 0.9rem; +} +.app-views button[aria-pressed='true'] { + background: #73d5c8; + border-color: #73d5c8; + color: #09111f; +} + +.workspace { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; + gap: 0.75rem; +} +.workspace__header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} +.workspace__header h2 { + margin: 0; + font-size: 1.1rem; + letter-spacing: 0.06em; +} +.workspace__status { + text-align: right; +} +.workspace-hint { + margin: 0.15rem 0; + font-size: 0.8rem; + color: #aebbd0; +} +.workspace-empty { + margin: 0.6rem 0; + font-size: 0.85rem; + color: #aebbd0; +} +.workspace-badge { + display: inline-block; + padding: 0.1rem 0.45rem; + border-radius: 0.35rem; + border: 1px solid #2a3750; + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #73d5c8; +} +/* The read-only marker is deliberately the loudest thing in the header. */ +.workspace-badge--strong { + margin: 0.3rem 0 0; + border-color: #73d5c8; + background: rgb(115 213 200 / 12%); +} +.workspace__project { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.8rem; + border: 1px solid #2a3750; + border-radius: 0.6rem; + background: #0c1626; +} +.workspace__project .workspace-hint { + word-break: break-all; +} +.workspace__error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.6rem 0.8rem; + border-radius: 0.5rem; + border: 1px solid #6b2a2a; + background: #2a1414; + color: #e88; +} +.workspace__error p { + margin: 0; +} +.workspace__panels { + flex: 1 1 auto; + display: grid; + grid-template-columns: minmax(16rem, 22rem) 1fr; + gap: 0.75rem; + min-height: 0; +} +@media (width <= 60rem) { + .workspace__panels { + grid-template-columns: 1fr; + } +} +.workspace__panel { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 0; + overflow-y: auto; + padding: 0.75rem; + border: 1px solid #2a3750; + border-radius: 0.6rem; + background: #0c1626; +} +.workspace__panel h3 { + margin: 0; + font-size: 0.9rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #73d5c8; +} +.workspace__panel h4 { + margin: 0.6rem 0 0.2rem; + font-size: 0.8rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #aebbd0; +} +.workspace__panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} +.workspace__panel-head button { + font-size: 0.75rem; + padding: 0.3rem 0.6rem; + background: transparent; + border: 1px solid #2a3750; + color: #aebbd0; +} + +.workspace-tree { + list-style: none; + margin: 0; + padding: 0; + font-size: 0.85rem; +} +.workspace-tree__row { + display: flex; + align-items: center; + gap: 0.4rem; +} +.workspace-tree__row--excluded { + opacity: 0.55; +} +.workspace-tree__row--current .workspace-tree__name { + color: #73d5c8; +} +.workspace-tree__button { + flex: 1 1 auto; + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.15rem 0.2rem; + background: transparent; + border: none; + color: #e8edf5; + font-size: 0.85rem; + font-weight: 400; + text-align: left; + cursor: pointer; +} +.workspace-tree__button:disabled { + cursor: default; +} +.workspace-tree__icon { + color: #73d5c8; +} +.workspace-tree__name { + word-break: break-all; +} +.workspace-tree__tag, +.workspace-tree__size { + flex: 0 0 auto; + font-size: 0.7rem; + color: #aebbd0; +} + +.workspace__search, +.workspace__plan-form { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid #2a3750; +} +.workspace__search label, +.workspace__plan-form label { + font-size: 0.75rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #aebbd0; +} +.workspace__search input, +.workspace__plan-form textarea { + padding: 0.45rem 0.6rem; + border-radius: 0.4rem; + border: 1px solid #2a3750; + background: #101a2c; + color: #e8edf5; + font-family: inherit; + font-size: 0.9rem; +} +.workspace__plan-form textarea { + resize: vertical; +} + +.workspace-matches { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.3rem; +} +.workspace-matches__button { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0.3rem 0.4rem; + background: #101a2c; + border: 1px solid #2a3750; + border-radius: 0.4rem; + color: #e8edf5; + font-weight: 400; + text-align: left; +} +.workspace-matches__path { + font-size: 0.75rem; + color: #73d5c8; + word-break: break-all; +} +.workspace-matches__excerpt { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; + color: #aebbd0; + white-space: pre; + overflow-x: auto; +} + +.workspace-file__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: #aebbd0; +} +.workspace-file__warning { + margin: 0.4rem 0; + padding: 0.5rem 0.6rem; + border: 1px solid #6b5a2a; + border-radius: 0.4rem; + background: #2a2414; + color: #e8c88a; + font-size: 0.8rem; +} +.workspace-file__content { + margin: 0.5rem 0 0; + max-height: 26rem; + overflow: auto; + padding: 0.6rem; + border: 1px solid #2a3750; + border-radius: 0.5rem; + background: #101a2c; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8rem; + line-height: 1.45; + /* `pre`, not `pre-wrap`: code alignment is meaningful. Horizontal + scrolling is confined to this element rather than the page. */ + white-space: pre; + color: #e8edf5; +} + +.workspace-plan { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid #2a3750; + font-size: 0.85rem; +} +.workspace-plan h3 { + margin-bottom: 0.3rem; +} +.workspace-plan__summary { + margin: 0.2rem 0; + color: #e8edf5; +} +.workspace-plan__steps, +.workspace-plan__files, +.workspace-plan__list { + margin: 0.2rem 0; + padding-left: 1.1rem; + color: #aebbd0; +} +.workspace-plan__files { + list-style: none; + padding-left: 0; +} +.workspace-plan__steps li, +.workspace-plan__files li, +.workspace-plan__list li { + margin-bottom: 0.3rem; +} +.workspace-plan__steps li strong, +.workspace-plan__files li code { + display: block; + color: #e8edf5; +} +.workspace-plan__approval { + margin-top: 0.8rem; + padding: 0.6rem 0.8rem; + border: 1px solid #2a3750; + border-radius: 0.5rem; + background: #101a2c; +} +.workspace-plan__approval p { + margin: 0 0 0.5rem; + color: #aebbd0; +} +.workspace-plan__approved { + color: #73d5c8; +} diff --git a/src/renderer/workspace/Workspace.tsx b/src/renderer/workspace/Workspace.tsx new file mode 100644 index 0000000..0e1029c --- /dev/null +++ b/src/renderer/workspace/Workspace.tsx @@ -0,0 +1,458 @@ +import { useState, type FormEvent } from 'react'; + +import { useWorkspace } from './useWorkspace'; +import { describeChatProviderStatus } from '../../shared/chat'; +import type { + CodingPlan, + WorkspaceEntry, + WorkspaceFile, + WorkspaceSearchResult, + ModelProviderSettings, +} from '../../shared/schemas'; + +interface WorkspaceProps { + readonly assistantName: string; + readonly modelProvider: ModelProviderSettings; +} + +/** + * How deep an entry is allowed to indent before the indentation stops + * growing. Purely visual; the tree's own depth bound is enforced in the main + * process. + */ +const MAX_INDENT_DEPTH = 8; + +function indentStyle(depth: number): { readonly paddingInlineStart: string } { + return { paddingInlineStart: `${String(Math.min(depth, MAX_INDENT_DEPTH) * 14)}px` }; +} + +/** + * One row of the file tree. + * + * `entry.name` is rendered as a plain JSX text child — React escapes every + * string child, so a file name, however hostile, cannot inject markup. The + * name has additionally already been refused by the main process if it + * carried a control character or a bidirectional override, so what reaches + * here renders as what it is. + */ +function TreeRow({ + entry, + onOpen, + disabled, + isOpen, +}: { + readonly entry: WorkspaceEntry; + readonly onOpen: (entry: WorkspaceEntry) => void; + readonly disabled: boolean; + readonly isOpen: boolean; +}) { + const openable = entry.kind === 'directory' ? !entry.excluded : entry.readable; + const classes = ['workspace-tree__row']; + if (entry.excluded) classes.push('workspace-tree__row--excluded'); + if (isOpen) classes.push('workspace-tree__row--current'); + + return ( +
  • + + {entry.excluded && excluded} + {entry.kind === 'file' && entry.size !== undefined && ( + {entry.size} B + )} +
  • + ); +} + +/** + * The read-only file viewer. + * + * `file.content` is a plain JSX text child, never `dangerouslySetInnerHTML`, + * and `white-space: pre` in the stylesheet preserves the layout without + * turning newlines into markup — the same treatment assistant messages get in + * `chat/Chat.tsx`, for the same reason: this is someone else's file, and it + * is untrusted input. + */ +function FileViewer({ file }: { readonly file: WorkspaceFile }) { + return ( +
    +
    + {file.metadata.name} + {file.metadata.path} + + {file.metadata.size} bytes · {file.metadata.lineCount} lines · {file.metadata.encoding} + + read-only +
    + {file.metadata.warnings.includes('bidirectional-control-characters') && ( +

    + This file contains bidirectional control characters. They can make the text read + differently from how it is stored — compare carefully before trusting what you see. +

    + )} +
    {file.content}
    +
    + ); +} + +function SearchResults({ + results, + onOpen, + disabled, +}: { + readonly results: WorkspaceSearchResult; + readonly onOpen: (path: string) => void; + readonly disabled: boolean; +}) { + if (results.matches.length === 0) { + return

    No match for “{results.query}”.

    ; + } + + return ( + <> +

    + {results.matches.length} match(es) in {results.filesScanned} file(s) + {results.truncated ? ' — results were truncated at the limit.' : '.'} +

    +
      + {results.matches.map((match) => ( +
    • + +
    • + ))} +
    + + ); +} + +/** + * The inert coding plan. + * + * Everything here is text produced by `src/shared/workspace/plan.ts` from + * observations about the project. There is no diff, because nothing generated + * file content to diff against — see `codingPlanSchema`, which pins `diff` to + * `null` so that stays true by construction rather than by omission. + */ +function PlanView({ + plan, + approved, + onApprove, + disabled, +}: { + readonly plan: CodingPlan; + readonly approved: boolean; + readonly onApprove: () => void; + readonly disabled: boolean; +}) { + return ( +
    +

    Proposed approach

    +

    {plan.objective.summary}

    + +

    Project context

    +

    + {plan.context.projectName} · {plan.context.filesInspected} file(s) inspected ·{' '} + {plan.context.hasGitMetadata ? 'version-controlled' : 'no version control detected'} ·{' '} + {plan.context.hasTestTooling ? 'test tooling detected' : 'no test tooling detected'} + {plan.context.searchTruncated ? ' · inspection was truncated' : ''} +

    + +

    Steps

    +
      + {plan.steps.map((step) => ( +
    1. + {step.title} + {step.detail} +
    2. + ))} +
    + +

    Files this would touch

    + {plan.expectedChanges.length === 0 ? ( +

    No file was identified for change.

    + ) : ( +
      + {plan.expectedChanges.map((change) => ( +
    • + {change.path} + {change.changeType} + {change.rationale} +
    • + ))} +
    + )} + +

    Change summary

    +

    {plan.changeSummary}

    + +

    Risks

    +
      + {plan.risks.map((risk) => ( +
    • {risk}
    • + ))} +
    + +

    Assumptions

    +
      + {plan.assumptions.map((assumption) => ( +
    • {assumption}
    • + ))} +
    + +
    + {approved ? ( +

    + Plan approved. Nothing was changed — this version of Local Agent has no ability to + modify a file, and approval unlocks none. +

    + ) : ( + <> +

    + This plan is a proposal. Approving it records your consent; it does not apply + anything, and no code path in this version can. +

    + + + )} +
    +
    + ); +} + +/** + * The read-only coding workspace (Phase 2, Milestone 5). + * + * Reaches the filesystem only through `useWorkspace` → `WorkspaceController` + * → `ipc-workspace-client.ts` → the preload bridge, and therefore only inside + * the approved project the user chose in a native picker. This component has + * no path of its own to a file, and nothing it renders can modify one. + * + * Every piece of text below that came from the project — a file name, a path, + * a search excerpt, a file's contents — is rendered as a plain JSX text + * child. `dangerouslySetInnerHTML` appears nowhere in this file, exactly as + * it appears nowhere in `chat/Chat.tsx`. + */ +export function Workspace({ assistantName, modelProvider }: WorkspaceProps) { + const { + state, + canAct, + selectProject, + refreshTree, + openFile, + search, + createPlan, + approvePlan, + retry, + } = useWorkspace(); + const [query, setQuery] = useState(''); + const [objective, setObjective] = useState(''); + + const providerStatus = describeChatProviderStatus(modelProvider); + const agentStatus = state.busy === null ? 'Idle' : `Working — ${state.busy}…`; + + function handleOpenEntry(entry: WorkspaceEntry): void { + if (entry.kind === 'directory') { + void refreshTree(entry.path); + return; + } + void openFile(entry.path); + } + + function handleSearch(event: FormEvent): void { + event.preventDefault(); + void search(query); + } + + function handlePlan(event: FormEvent): void { + event.preventDefault(); + void createPlan(objective); + } + + return ( +
    +
    +
    +

    Coding workspace

    +

    Read-only

    +
    +
    +

    Model: {providerStatus.summary}

    +

    + Agent: {agentStatus} +

    + {state.activity !== null && ( +

    + Last activity: {state.activity} +

    + )} +
    +
    + +
    + + {state.project === null ? ( +

    + No project is open. Local Agent can only read inside a directory you approve, and can + never modify it. +

    + ) : ( +

    + {state.project.name} — {state.project.path} + {state.project.markers.length > 0 && · {state.project.markers.join(', ')}} + {state.project.hasGitMetadata && · Git} +

    + )} +
    + + {state.error && ( +
    +

    {state.error.message}

    + {state.error.retryable && ( + + )} +
    + )} + + {!state.initialized &&

    Loading workspace…

    } + + {state.initialized && state.project === null && ( +

    + Select a project to list its files, read them, and prepare a coding plan. +

    + )} + + {state.project !== null && ( +
    +
    +
    +

    Files

    + +
    + {state.busy === 'tree' &&

    Listing…

    } + {state.tree === null && state.busy !== 'tree' && ( +

    No listing yet.

    + )} + {state.tree !== null && state.tree.entries.length === 0 && ( +

    This folder is empty.

    + )} + {state.tree !== null && state.tree.entries.length > 0 && ( + <> + {state.tree.root.length > 0 && ( +

    In {state.tree.root}

    + )} +
      + {state.tree.entries.map((entry) => ( + + ))} +
    + {state.tree.truncated && ( +

    + The listing reached its limit; some entries are not shown. +

    + )} + + )} + +
    + + { + setQuery(event.target.value); + }} + /> + +
    + {state.busy === 'search' &&

    Searching…

    } + {state.search !== null && state.busy !== 'search' && ( + void openFile(path)} + disabled={!canAct} + /> + )} +
    + +
    +

    Viewer

    + {state.busy === 'file' &&

    Opening…

    } + {state.openFile === null && state.busy !== 'file' && ( +

    Select a file to read it.

    + )} + {state.openFile !== null && } + +
    + +