diff --git a/CLAUDE.md b/CLAUDE.md index ab137f29..30502d57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,6 +214,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `๐Ÿ“ Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `โŒซ All` key clears only the unsent prompt and must never send the agent's `/clear`. โš ๏ธ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** โ€” it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. โš ๏ธ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. โ†’ [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) +**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place โ€” `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` โ†’ 409. โš ๏ธ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). โš ๏ธ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. โ†’ [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md` + **Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. โ†’ [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization) **Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. โ†’ [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index d0dfd805..88becd3a 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -87,6 +87,19 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough The general rule: **any new endpoint that turns a caller-supplied `sessionId` into a filesystem path is an ownership boundary**, whether or not it goes through `findSessionOrFail`. +### File Viewer edit mode + +**File Viewer edit mode** (issue #212, design in `docs/file-viewer-edit-plan.md`): the file-preview overlay can edit workspace text files in place โ€” `GET /api/sessions/:id/file-content?edit=1` (read-for-edit) + `PUT /api/sessions/:id/file-content` (save), policy in `src/config/file-editing.ts`, UI in `panels-ui.js`. This is the **only file surface that writes**, so it carries every rule the read surfaces have plus its own: + +- **Confinement is the read path's, plus write-only gates.** `findSessionOrFail` (ownership) โ†’ `validateSessionFilePath` (realpath + workspace boundary; escapes report as 404, same as reads) โ†’ sensitive-path + attachment-guard blocklists (403) โ†’ `.git/` subtree deny (403 โ€” `.git/hooks/*` is code execution) โ†’ extension **allowlist** (400; `svg` and `env` deliberately excluded). โš ๏ธ **There is no `O_CREAT` anywhere in the handler** โ€” that absence is what makes "edit-in-place only, never create" a structural property instead of a convention. Do not add a create path without treating it as a new security surface. +- **A truncated buffer must never become an edit buffer.** The plain preview truncates to `lines` (default 500); saving such a buffer would silently delete everything past the cut, and the hash check cannot catch it (the loaded prefix hashes differently from the full file, which reads as an ordinary conflict at best). `edit=1` therefore never truncates โ€” it 413s over `MAX_EDITABLE_BYTES` (512KB) instead โ€” and the frontend always re-fetches with `edit=1` before swapping in the textarea, even though the preview already holds content. +- **Concurrency is optimistic by content hash, not mtime.** The client echoes the sha256 it loaded (`baseHash`); mismatch โ†’ 409 CONFLICT (plain envelope โ€” the error arm carries no data; the client re-fetches `edit=1` for fresh state) unless `force:true`. mtime alone is wrong: agents rewrite files within one timestamp tick. +- **Writes are `wx` temp + `fchmod` + `fsync` + `rename` in the target's directory.** `wx` cannot follow a pre-existing symlink and `rename()` replaces (not follows) a symlink final component, which closes the validate-then-write TOCTOU window; `fchmod` because `open()`'s mode argument is masked by the umask; a symlink whose target is *inside* the workspace is deliberately written through (validation returns the realpath). Trade-off (same as vim): the inode changes, so hardlinks keep old content. +- **Corruption guards**: NUL-sniff + UTF-8 **round-trip compare** (`Buffer.from(buf.toString('utf8'), 'utf8').equals(buf)`) refuse binary and non-UTF-8 files โ€” decoding latin-1 yields U+FFFD replacements and writing those back destroys the original bytes. EOL is detected server-side and re-applied on save because a `