Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ai/contexts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ without re-reading `main.js`, now ~2600 LOC.
| Claude CLI state files, early subagent rescan, canary tests | [cli-session-state](cli-session-state.md) |
| Busy/attention/response-ready state, the session-state domain module, the icon-slot projection | [session-state](session-state.md) |
| The Changes panel: git-status parser, local/remote runner, cwd resolution, no-polling refresh | [changes-view](changes-view.md) |
| The Touched tab: files a session's file tools touched, from its transcript and its subagents', checked against disk | [touched-files](touched-files.md) |
| The shell inside the file panel: mount point, hidden-write exemption, shell lifecycle, the splitter | [panel-terminal](panel-terminal.md) |
| How a plain terminal's shell starts: the `claude` shim per shell, the generated rcfile and `ZDOTDIR`, the typed fallback | [plain-terminal](plain-terminal.md) |
| Paths in terminal output becoming links: the matcher, the openability check, `path:line` | [terminal-path-links](terminal-path-links.md) |
Expand Down
11 changes: 11 additions & 0 deletions .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,16 @@ design (parser, runner, quoting, cwd resolution, refresh triggers, editing):
boundary in either direction: the session's cwd is re-resolved main-side on
every call, and the absolute path built from it is used and discarded there.

### Touched files (issue #309)

| IPC | Args | Returns | Notes |
|---|---|---|---|
| `session-touched-files` | `(sessionId)` | `{ok, files, unresolved, omitted, coverage} \| {ok:false, reason?, error}` | The files the session's file tools touched, from its transcript and its subagents'. `files` rows are `{path, state, openable, tools, count, sources}` with `state` one of `present`, `gone`, `unreadable`, `refused`, `not-file`; `unresolved` rows carry the raw text and a reason, never a `path`. `reason` is `remote` or `no-transcript`. Local sessions only; a `sub:` id is refused. Full design: `.ai/contexts/touched-files.md`. |

This is the one handler whose *output* is a list of absolute paths taken from
attacker-influenced data. Listing is not opening: the renderer opens a row
through `read-file-for-panel` and its guards, never through this IPC.

### Misc

| IPC | Notes |
Expand Down Expand Up @@ -185,6 +195,7 @@ Every handler that takes a renderer-supplied path or derives a spawn location fr
| `read-session-jsonl` / `read-subagent-jsonl` / `start-subagent-watch` / `create-schedule-session` | none directly — path is derived from a SQLite key or built via `encodeProjectPath`, not taken verbatim from the renderer | out of scope for a path guard; flag if a renderer-controlled string is ever found reaching the derivation unencoded |
| `git-changes-file` / `git-changes-watch` / `git-changes-unwatch` / `git-changes-locate` | `isSafeRevPathOperand` + `resolveTargetInsideRepo` (`git-changes-file.js`): the repo root and git directories come from `git rev-parse --show-toplevel --absolute-git-dir --git-common-dir`, a symlink at the target is refused before anything follows it, and **every remaining check runs on the disk-resolved path** — containment in the root, no `.git` segment, nothing inside a git directory, `isSensitivePath`, regular file | shape + disk-resolved containment + denylist — the operand is `<rev>:<path>`, a *revision*, not a pathspec: `--literal-pathspecs` does not reach it and `--` cannot separate it, so it carries its own guard. See `.ai/contexts/changes-view.md` ("Editing a changed file") |
| `git-changes-save` | `isSafeRepoRelativePath` + the same `resolveTargetInsideRepo`, plus a version token that must still match the bytes on disk; the write runs on the path the guard returned, never on a re-derived one | shape + disk-resolved containment + denylist — **the only write handler in the app whose entire input is a relative path from the renderer**, so containment is the guard, not an afterthought; `save-file-for-panel` next to it has none (it takes an absolute path and checks only `isSensitivePath`) and is not the precedent to copy here |
| `session-touched-files` | `isValidChangesSessionId` (no subagent shape), a plain-folder-name check on the cached folder, `resolveTouchedPath` (absolute, no UNC / `\?\` / device form, no control character; relative only against a `verifiedTranscriptCwd`), `isSensitivePathAsync` on every resolved path before its `stat` | shape + disk-resolved denylist — **stat-only**: a path that fails the denylist is listed `refused` and never stat-ed; nothing is read. The click goes to `read-file-for-panel` |
| `git-changes-diff` | `isSafeGitPath`, or `isSafeNoIndexPath` + containment when `untracked` (`git-changes-runner.js`) | a git pathspec relative to an arbitrary (possibly remote) cwd; see `.ai/contexts/changes-view.md` ("Quoting rule") for why this is a denylist, not an allowlist. The untracked variant is a real filesystem operand of `git diff --no-index`, which has no repository-boundary check of its own: on top of the syntactic guard it is resolved with `realpath`/`stat` against the resolved cwd (local) or checked against `git ls-files --others` (remote), git receives the guard's operand rather than the caller's, and the returned diff must name that same path in its `diff --git` line — see "Untracked files" in the same doc |

### Sensitive-path candidates
Expand Down
126 changes: 126 additions & 0 deletions .ai/contexts/touched-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Context: touched-files

**Purpose**: a per-session list of the files the session's *file tools* touched,
in the right-hand file panel, for a local session. It answers "who touched what"
where Changes cannot: outside any repository, in a directory with no git, when
sessions share a tree, and after a change was committed or reverted. Issue #309.
User-facing behavior: `docs/touched-files.md`. IPC and its guard row:
`.ai/contexts/ipc-bridge.md` ("Touched files").

## Key files

| File | Role |
|---|---|
| `session-touched-files.js` | Main-side module, no electron: `extractTouches(line)`, `resolveTouchedPath(raw, {cwd})`, `collectSessionTouchedFiles(opts)` (the walk, with every dependency injected) and `listSessionTouchedFiles(sessionId, deps)` (the IPC's target resolution). |
| `public/touched-files-view.js` | Renderer: the `'touched'` tab type, its container, rows, toggle and refresh. Loaded after `file-panel.js`, which reaches it behind `typeof` (`initTouchedView`, `renderTouchedTab`, `hideTouchedView`). |
| `read-session-file.js` | `enumerateSessionFiles(folderPath)` lists the parent and subagent transcripts (both layouts); the module keeps the entries that belong to the session. |

## What "touched" means, and what it does not

A row exists when a transcript holds an assistant `tool_use` block named `Edit`,
`Write`, `MultiEdit` or `NotebookEdit` whose input has a non-empty string
`file_path` (`notebook_path` for `NotebookEdit`). Nothing else counts. This is a
**lower bound**: measured on one project, 522 of 2829 tool calls carried a path,
2182 were `Bash`, and in one session 385 `Bash` calls against 27 file-tool calls
made nearly every changed file invisible here. Parsing shell commands was
considered and rejected (redirections, pipes, `find -exec`, computed names: any
coverage figure would be a guess).

So the tab says what it is where it is read: `#touched-coverage`, a static
string in the renderer (`TOUCHED_COVERAGE_TEXT`) that is in the DOM while the
list loads, when it fails and when it is empty. `test/dom-file-panel-touched.test.js`
pins it. The empty state reads "No files touched by the file tools", never
"nothing changed". Changes stays the authority on the working tree.

The transcript records intent, not outcome: a refused `Write` is listed. Each
row is checked against the disk when the list is built (`state`): `present`,
`gone` (`ENOENT`/`ENOTDIR`), `not-file`, `unreadable` (any other error, or no
answer in 3 s), or `refused` (see below). The list is not live: it is built on
open and on the refresh button, not on every busy-to-idle edge, because a
build reads every transcript of the session (up to 256 MiB in total, then
`coverage.truncated`) and stats up to 500 paths.

## Trust: the paths are attacker-influenced

A sandboxed session writes its own transcripts, so every path here is data an
attacker may have chosen. Listing one is harmless; the guards are about what the
listing *does* with it.

- **`resolveTouchedPath`** accepts an absolute path, normalised, and refuses
(the row goes to `unresolved`, with no `path` field at all): control
characters, which means C0, DEL, C1, U+2028/2029 and every `\p{Cf}` (bidi
overrides and isolates, LRM/RLM/ALM, zero-width, tag characters; one of them
makes `report<U+202E>txt.exe` display reversed while opening the real file),
over 4096 characters, a leading `~`, on Windows any leading
double separator (UNC, `\\?\`, `\\.\`: a `stat` on a UNC path reaches the
network), a rooted path with no drive and a drive-relative one.
- **A relative path** resolves only against a cwd passed through
`verifiedTranscriptCwd` (`encode-project-path.js`, #419): the transcript's own
`cwd` must encode back to the folder it sits in. The parent and each subagent
are verified on their own transcript, so a subagent in a worktree (whose cwd
encodes to another folder) leaves its relative paths `unresolved` with reason
`relative-no-cwd`. The cwd of the Changes target is not used.
- **Before any `stat`**, every resolved path goes through `isSensitivePathAsync`
(credential-directory denylist, 8.3 and `\\?\` handling, fail closed). A hit,
or a check that throws, gives `state: 'refused'` and the path is never
stat-ed. The row stays in the list so the user sees the session reached for
it.
- **Listing is not opening.** The renderer opens a row with `readFileForPanel`
(`read-file-for-panel`, its own `isSensitivePath`, regular-file, size and
binary checks) and then the ordinary file tab. Only a row with
`openable === true` **and** `state === 'present'` has a click handler; a source
check (`test/touched-files-wiring.test.js`) pins that the view calls no other
`window.api` method than `sessionTouchedFiles` and `readFileForPanel`. The
absolute path the renderer passes is one the main process returned.
- **The folder** comes from `getCachedFolder` and must be a plain name (no
separator, not `.` or `..`) before it is joined to the projects directory; a
`sub:` session id and a remote folder are refused.
- **Rendering** is `textContent` throughout, an unresolved path shows its unsafe code points as visible escapes (`\u202E`, `\u{E0041}`), `.touched-file-path` is `unicode-bidi: isolate`; sources (subagent type from the
`.meta.json` sidecar) are stripped of control characters and cut to 80.

## Bounds

- 500 distinct resolved rows and 500 unresolved; the overflow is a counter
(`omitted`; duplicates of an overflowing path count again), not a kept set.
- A prefilter (`tool_use` plus a quoted tool name) keeps `JSON.parse` off every
other line. A line that passes it is skipped past 4 MiB: a tool call carries
at most a model output, a few hundred KB, so a larger one is not a tool call
worth parsing. A line over 32 MiB without a newline is skipped as well. Both
are counted in `coverage.skippedLines` and the summary says so.
- 256 MiB of transcript in total across the session's files, then
`coverage.truncated`.
- **The disk check is bounded as a whole.** The sensitivity guard
(`isSensitivePathAsync`: realpath and lstat) and the `stat` run under one
3 s timeout per path, with 8 paths in flight. A timeout makes the row
`unreadable`. A timed-out call still holds a libuv thread, so after 8
timed-out checks no new call is issued and the remaining rows are
`unreadable` without being checked: 500 planted paths on an offline mapped
drive hold at most about 16 threads, not 500.

## Decisions that were open in the issue

- **Placement**: its own tab and header toggle (`Touched`, after `Changes`),
not a section of the Changes list; the issue puts changing the Changes panel
out of scope.
- **Remote sessions**: the issue is silent; local only. The IPC answers
`reason: 'remote'` and the tab shows the message. Remote transcripts are
mirrored copies, but their paths name the host's disk, which cannot be stat-ed
from here.
- **Ordering**: first touch, parent transcript first, then subagents by file
name; no timestamps.
- **Open**: a row opens the plain file viewer. It does not route to the Changes
diff when the file is also changed in the working tree (`openFileInPanel` does,
for terminal links); doing so needs a Changes target, which a file outside any
repository does not have.

## Not covered

- Files touched through `Bash`, MCP tools or any tool other than the four.
- Attribution between sessions sharing a directory, beyond the `sources` labels.
- A tool result that says the call failed (`is_error`) is not read.

## If you change this, also check

- `test/session-touched-files.test.js` (extraction, resolution, walk, target resolution), `test/dom-file-panel-touched.test.js`, `test/touched-files-wiring.test.js`
- `public/header-controls.js` (`HEADER_CONTROLS`, the icon) and `test/header-controls.test.js`
- the guard row in `.ai/contexts/ipc-bridge.md`
1 change: 1 addition & 0 deletions .ai/shared-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Switchboard is an **Electron desktop app**: renderer + main-process, no Domain/A
| Read the Claude CLI's own session state files | [contexts/cli-session-state.md](contexts/cli-session-state.md) |
| Change Memory/.work-files panels (CodeMirror) | [contexts/viewer-panel.md](contexts/viewer-panel.md) |
| Change the Changes panel (git-status parser, local/remote runner, cwd resolution) | [contexts/changes-view.md](contexts/changes-view.md) |
| Change the Touched tab (files a session's file tools touched, from transcripts) | [contexts/touched-files.md](contexts/touched-files.md) |
| Change the shell inside the file panel (mount point, hidden-write exemption, splitter) | [contexts/panel-terminal.md](contexts/panel-terminal.md) |
| Change how a plain terminal's shell starts (the `claude` shim, generated rcfile / `ZDOTDIR`, the typed fallback) | [contexts/plain-terminal.md](contexts/plain-terminal.md) |
| Change what a path in terminal output links to (matcher, openability check, `path:line`) | [contexts/terminal-path-links.md](contexts/terminal-path-links.md) |
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
## Unreleased

### New
- A session's **Touched** tab, next to Changes in the terminal header, lists the files its file tools (Edit, Write, MultiEdit, NotebookEdit) touched, its subagents' included, with what is on disk now (present, gone, unreadable) and the tools and agents behind each. It works outside any git repository. It is not the complete set of files the session changed: files changed through Bash commands or scripts are not listed, and the tab says so. Local sessions only. (#309)
- With Debug mode on, the activity trace now records how hard each terminal is being drawn: once a second per session, how many writes reached it, how large they were and how often its glyph atlas was rebuilt, to tell a legitimately busy terminal from a runaway one. (#175)
### Changed
- A trigger that gave up waiting for a session now says, in its result file's `reason`, when the session was blocked on a dialog such as a permission prompt or a question: for a single trigger, a chain's first wait, and a chain step whose turn never finished. Without a dialog the result is as before. (#379)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ its sessions from `~/.claude/projects`. Installed builds update themselves; see
| Subagent hierarchy, live status, transcripts | [Subagents](docs/subagents.md) |
| Claude's file opens and proposed edits in a side panel | [IDE emulation](docs/ide-emulation.md) |
| A session's git changes, with an editor | [Changes view](docs/changes-view.md) |
| The files a session's file tools touched | [Touched files](docs/touched-files.md) |
| `CLAUDE.md`, memory files and `.work-files/` | [Agent Files and Work Files](docs/memory-workfiles.md) |
| Activity heatmap, token counts, rate limits | [Stats](docs/activity-stats.md) |
| Reopening the open sessions after a restart | [Session restore](docs/session-restore.md) |
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ links are in the [README](../README.md#download).
| [Subagents](subagents.md) | Subagent rows, their live status, the read-only transcript viewer |
| [IDE emulation](ide-emulation.md) | Switchboard as Claude's IDE: file opens and diffs in a side panel |
| [Changes view](changes-view.md) | A session's git status and diffs, with an editor for local sessions |
| [Touched files](touched-files.md) | The files a session's file tools touched, including outside any repository |
| [Agent Files and Work Files](memory-workfiles.md) | The two file tabs: `CLAUDE.md` and memory files, schedules, `.work-files/` |
| [Stats](activity-stats.md) | Heatmap, totals, per-model tokens, rate limits |
| [Session restore](session-restore.md) | Reopening the open sessions at the next launch |
Expand Down
45 changes: 45 additions & 0 deletions docs/touched-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Touched Files

**Touched** lists the files a session's file tools touched: what it created or
edited with Edit, Write, MultiEdit or NotebookEdit, including what its subagents
did. Unlike [Changes](changes-view.md), it does not need a git repository, so it
shows files outside any repository too. It is for a local session; a remote
session has no Touched list.

## What the list is, and is not

It is **not** the complete set of files the session changed. Files changed
through Bash commands (`sed`, a heredoc, a script) or any other tool are not
listed, and in a typical session those are most of them. The panel says so at the
top, and an empty list means "the file tools touched nothing", not "nothing
changed". [Changes](changes-view.md) is still the answer to what differs in a
working tree.

## Opening it

**Touched** in the terminal header, next to **Changes**, opens the panel; clicking
it again closes it. Each row shows the path, what Switchboard found on disk, the
tools used, how many times, and who made the calls: the session or a subagent.
The list is read when the panel opens and on **Refresh**; it does not update by
itself.

## What each row says about the disk

The transcript records what the session tried, not what happened, so every row is
checked against the disk when the list is built:

- **present**: the file is there. Click it to open it in the file viewer.
- **gone**: the file no longer exists, or never did (a refused write).
- **not a file**, **unreadable**: it is a directory, or it could not be read.
- **refused**: the path is in a protected location, such as a credential
directory. It is listed but never opened.

A path that cannot be tied to a file is listed under **Not resolved to a file**
with the reason: a relative path whose session directory could not be verified,
a network path, or a path with unusable characters. It cannot be opened.

## Limits

- At most 500 files are listed; the summary counts the rest.
- A very large transcript is read only in part, and the summary says so.
- A row opens in the same file viewer as a [path link](terminal.md#clickable-paths); the list itself never writes.
Loading
Loading