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
5 changes: 3 additions & 2 deletions .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ adapter applies it.
- **Path-touching IPCs (`read-work-file`, `delete-work-file`, `read-memory`, `run-schedule-now`, `delete-worktree`, etc.) MUST guard their paths, and the guard must resolve on disk, not just `path.resolve()`**. `path.resolve()` normalises `..` but does not follow symlinks — a path can look contained by every string check and still open a file somewhere else through a symlinked directory. The shared primitive is `resolveOnDisk()` in `resolve-path-on-disk.js` (realpath, or `null` when nothing exists there yet); it is called *inside* the guards, not duplicated at each call site:
- `isSensitivePath` / `resolveAllowedMemoryPath` (+ its boolean form `isAllowedMemoryPath`) / `isKnownProjectRoot` (`ipc-path-validator.js`) — denylist, allowlist, and exact-match-against-known-projects, used by the memory, file-panel and worktree handlers. `resolveAllowedMemoryPath` returns the resolved path, not a boolean, and callers that go on to read/write MUST use that returned value for the operation — a caller that re-resolves its own literal path afterwards reopens a TOCTOU (checked path and used path can diverge if a symlinked ancestor is swapped in between). See `resolve-path-on-disk.js` for the documented gap when the target does not exist yet.
- `resolveDeletionTargets` (`delete-session-target.js`) — session deletion, the first handler to need this and the source the primitive was extracted from.
- `resolveRunNowTarget` (`run-schedule-now-target.js`) — `run-schedule-now`'s shape + allowlist check, run before any read or spawn.
- `resolveRunNowTarget` (`run-schedule-now-target.js`) — `run-schedule-now`'s shape + allowlist check, run before any read or spawn. Four duties, and which path each one answers for matters: the **directory shape** (`.claude/commands` parents) is checked on the *requested* path, because what makes a file a schedule is the directory that lists it, and a versioned configuration is linked in there from a repo elsewhere on disk; the **filename** is checked on the requested path *and again on the disk-resolved target*, so the listing directory decides where a schedule may be linked from but never what it may point at; `isSensitivePath` refuses a resolved target in a credential location; and `isAllowedMemoryPath` runs on the disk-resolved file **and** on the disk-resolved project root the run is spawned in, which with a linked-in schedule is not derivable from the file. Dropping any one of the four is caught by `test/run-schedule-now-target.test.js`.
Work-files still use the narrower `.includes('/.work-files/')` substring check — same weakness against a symlinked ancestor, not yet migrated (see "IPC path-guard inventory" below). A handler that reads *content* back to the renderer (file panel, memory) and a handler that only decides *whether to run a command* (`run-schedule-now`) both need a guard, and neither one is safe with `path.resolve()` alone.
- **Trust boundary is the contextBridge call**. Anything passed across must survive structured-clone serialization. No functions, no DOM nodes, no class instances — only plain JSON.
- **Async handlers return promises**. Renderer uses `await window.api.foo(...)`. Throws cross the boundary as rejected promises; return `{ok, error}` if you want graceful failure handling on the renderer side.
Expand All @@ -147,10 +147,11 @@ Every handler that takes a renderer-supplied path or derives a spawn location fr
| IPC | Guard | Kind |
|---|---|---|
| `read-memory` / `save-memory` | `resolveAllowedMemoryPath` (read/write the returned path, not the caller's own re-resolved one) | disk-resolved allowlist |
| `get-memories` (the list, and the FTS bodies it indexes) | `scanMdFiles` (`scan-md-files.js`) applies `isSensitivePath` itself and the `isAllowedMemoryPath` predicate `get-memories` passes in | disk-resolved denylist + allowlist — a listed file is read twice more downstream, once by `read-memory` behind `resolveAllowedMemoryPath` and once by the FTS indexer behind nothing at all, so the guard belongs on the list. The denylist is not the caller's to choose: containment in an allowed project root says nothing about a cloned repo's own `.ssh`/`.env` |
| `open-path` / `read-file-for-panel` / `save-file-for-panel` / `watch-file` | `isSensitivePath` | disk-resolved denylist |
| `delete-worktree` / `worktree-status` | `WORKTREE_PATH_RE` (shape) + `isKnownProjectRoot` (disk-resolved exact match) | shape + disk-resolved allowlist |
| `delete-session-preview` / `delete-session` | `resolveDeletionTargets` (`delete-session-target.js`) | disk-resolved containment |
| `run-schedule-now` | `resolveRunNowTarget` (`run-schedule-now-target.js`), which composes filename shape + `isAllowedMemoryPath` | disk-resolved allowlist — **the only handler in the app that both reads a file and spawns a process from a renderer-supplied path; had no guard at all before this pass** |
| `run-schedule-now` | `resolveRunNowTarget` (`run-schedule-now-target.js`), which composes directory shape (requested path) + filename shape (requested **and** resolved) + `isSensitivePath` + `isAllowedMemoryPath` on both the resolved file and the resolved project root | disk-resolved allowlist + denylist — **the only handler in the app that both reads a file and spawns a process from a renderer-supplied path; the file's content becomes a prompt sent to the model, so the resolved target is constrained, not just its location** |
| `read-work-file` / `delete-work-file` | `.includes('/.work-files/')` substring | ad hoc string, **not disk-resolved** — a symlinked ancestor defeats it the same way it defeated `isSensitivePath`/`isAllowedMemoryPath` before they were fixed here. Not migrated in this pass; same fix shape (`resolveOnDisk` + a `.work-files` component check instead of a substring test) would close it |
| `read-activity-trace-file` / `delete-activity-trace-file` | `resolveTraceFilePath` (`activity-trace.js`) | ad hoc string (directory + basename pattern), **not disk-resolved** — narrower surface (one generated file family) lowers the stakes, not migrated |
| `add-project` / `remap-project` | none on the probe (`fs.statSync`/`fs.existsSync`/`fs.lstatSync`); the actual write is confined through `encodeProjectPath` | existence/type oracle only — inherent to the feature (both accept an arbitrary disk location by design), not cheaply fixable without breaking it |
Expand Down
3 changes: 3 additions & 0 deletions .ai/contexts/schedule-runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ cli:

## Non-obvious behaviors

- **A schedule file may be a symlink.** `scanSchedules` lists names and reads through them, so a `schedule-*.md` linked in from a versioned dotfiles repo fires on cron like any other. The two places that *don't* go through that scanner had to be taught the same thing: the brain tab's listing (`scan-md-files.js` — accepted entries on `dirent.isFile()`, false for a symlink, so a linked-in schedule was invisible in the UI while still firing weekly) and the run-now guard (`run-schedule-now-target.js` — checked the `.claude/commands` shape against the resolved target instead of the listed path). If you add a third reader of these files, resolve the link rather than the dirent.
- **`run-schedule-now` resolves the file and the cwd separately, and they may differ.** `resolveRunNowTarget` takes the schedule's bytes from the link's target and roots the spawn at the project whose `.claude/commands` lists it; each is disk-resolved and allowlisted in its own right, neither is derived from the other. With `projA/.claude/commands/schedule-x.md -> projB/.claude/commands/schedule-x.md` and both projects known, the run executes projB's content with `cwd = projA`. That is the semantic, not an oversight — `test/run-schedule-now-target.test.js` pins it so it is not "corrected" in either direction by accident.
- **Hand-rolled cron parser** in `cronFieldMatches` / `cronMatches`. Supports `*`, comma lists (`1,2,3`), ranges (`1-5`), steps (`*/5`). No support for `@daily`/`@hourly` aliases. No DST awareness — `new Date()` is local-time.
- **No persistence across app close** — the scheduler runs in-process. If Switchboard isn't running at 9am, the 9am schedule doesn't fire. By design (this is a personal tool, not a daemon).
- **The "schedule creator" is itself a Claude command**: when the user clicks the clock icon on a project, Switchboard opens an interactive Claude session pre-injected with `SCHEDULE_CREATOR_TEMPLATE` as its system prompt. Claude then has a conversation with the user about what they want scheduled, and **Claude itself writes the schedule `.md` file** with the Write tool. The runner just consumes whatever files appear.
Expand All @@ -62,6 +64,7 @@ cli:

- `public/dialogs.js` (`launchScheduleCreator`) — UI entry point for the schedule creator flow
- `public/memory-workfiles-view.js` brain tab — lists existing `schedule-*.md` files, surfaces the "run now" play button
- `scan-md-files.js` — what that brain tab list is actually built from (`get-memories` in `main.js`); it decides whether a schedule file is visible at all, and takes the memory allowlist so the list carries nothing the readers behind it would refuse to open
- `public/sidebar.js` — `.project-schedule-btn` clock icon wiring per project
- `schedule-ipc.js` `SCHEDULE_CREATOR_TEMPLATE` — if you change the schedule file format, update the template's instructions
- `main.js:2517` (or wherever `startScheduler(log, runScheduleCommand)` is invoked at app boot — checked 2026-09, it moves as main.js grows)
Expand Down
31 changes: 6 additions & 25 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@
}

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles');

Check warning on line 73 in main.js

View workflow job for this annotation

GitHub Actions / lint

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 73 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { scanMdFiles } = require('./scan-md-files');
const { isSensitivePath, isAllowedMemoryPath: _isAllowedMemoryPath, resolveAllowedMemoryPath: _resolveAllowedMemoryPath, isKnownProjectRoot: _isKnownProjectRoot } = require('./ipc-path-validator');
const { validatePreLaunchCmd } = require('./pre-launch-cmd-guard');
const { normalizePtySize } = require('./pty-size');
Expand Down Expand Up @@ -456,8 +457,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 460 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 460 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 461 in main.js

View workflow job for this annotation

GitHub Actions / lint

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
scanFoldersViaWorker, setRemoteRoots, resolveFolderDir } = sessionCache;
const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file');

Expand Down Expand Up @@ -1225,32 +1226,12 @@
return meaningful.slice(-2).join('/');
}

/** Scan a directory for .md files (non-recursive). Returns array of { filename, filePath, modified }. */
function scanMdFiles(dir) {
const results = [];
try {
if (!fs.existsSync(dir)) return results;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
if (e.isFile() && e.name.endsWith('.md')) {
const fp = path.join(dir, e.name);
const content = fs.readFileSync(fp, 'utf8').trim();
if (content) {
const stat = fs.statSync(fp);
results.push({ filename: e.name, filePath: fp, modified: stat.mtime.toISOString() });
}
}
}
} catch {}
return results;
}

ipcMain.handle('get-memories', () => {
const global = getSetting('global') || {};
const hiddenProjects = new Set(global.hiddenProjects || []);

// --- Global files ---
const globalFiles = scanMdFiles(CLAUDE_DIR).map(f => ({ ...f, displayPath: '~/.claude' }));
const globalFiles = scanMdFiles(CLAUDE_DIR, isAllowedMemoryPath).map(f => ({ ...f, displayPath: '~/.claude' }));

// --- Per-project files ---
const projects = [];
Expand All @@ -1275,14 +1256,14 @@
const seenPaths = new Set();

// 1. ~/.claude/projects/{folder}/ — claude-home .md files
const claudeHomeFiles = scanMdFiles(folderPath);
const claudeHomeFiles = scanMdFiles(folderPath, isAllowedMemoryPath);
for (const f of claudeHomeFiles) {
files.push({ ...f, displayPath: '~/.claude', source: 'claude-home' });
seenPaths.add(f.filePath);
}
// memory/MEMORY.md
const memoryDir = path.join(folderPath, 'memory');
const memoryFiles = scanMdFiles(memoryDir);
const memoryFiles = scanMdFiles(memoryDir, isAllowedMemoryPath);
for (const f of memoryFiles) {
files.push({ ...f, displayPath: '~/.claude', source: 'claude-home' });
seenPaths.add(f.filePath);
Expand All @@ -1306,7 +1287,7 @@

// 3. {projectPath}/.claude/ — commands/*.md and other .md files
const dotClaudeDir = path.join(projectPath, '.claude');
const dotClaudeFiles = scanMdFiles(dotClaudeDir);
const dotClaudeFiles = scanMdFiles(dotClaudeDir, isAllowedMemoryPath);
for (const f of dotClaudeFiles) {
if (!seenPaths.has(f.filePath)) {
files.push({ ...f, displayPath: shortName + '/.claude/', source: 'project' });
Expand All @@ -1315,7 +1296,7 @@
}
// commands/*.md
const commandsDir = path.join(dotClaudeDir, 'commands');
const commandFiles = scanMdFiles(commandsDir);
const commandFiles = scanMdFiles(commandsDir, isAllowedMemoryPath);
for (const f of commandFiles) {
if (!seenPaths.has(f.filePath)) {
files.push({ ...f, displayPath: shortName + '/.claude/commands/', source: 'project' });
Expand Down Expand Up @@ -2293,7 +2274,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 2277 in main.js

View workflow job for this annotation

GitHub Actions / lint

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
29 changes: 22 additions & 7 deletions run-schedule-now-target.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

const path = require('path');
const { resolveOnDisk } = require('./resolve-path-on-disk');
const { isSensitivePath } = require('./ipc-path-validator');

const SCHEDULE_FILENAME_RE = /^schedule-.*\.md$/i;

Expand All @@ -20,7 +21,20 @@ function resolveRunNowTarget(filePath, isPathAllowed) {
return { ok: false, error: 'invalid path' };
}

const real = resolveOnDisk(filePath);
const requested = path.resolve(filePath);

if (!SCHEDULE_FILENAME_RE.test(path.basename(requested))) {
return { ok: false, error: 'not a schedule file' };
}

const commandsDir = path.dirname(requested);
const dotClaudeDir = path.dirname(commandsDir);

if (path.basename(commandsDir) !== 'commands' || path.basename(dotClaudeDir) !== '.claude') {
return { ok: false, error: 'not inside a project .claude/commands directory' };
}

const real = resolveOnDisk(requested);
if (!real) {
return { ok: false, error: 'file not found' };
}
Expand All @@ -29,15 +43,16 @@ function resolveRunNowTarget(filePath, isPathAllowed) {
return { ok: false, error: 'not a schedule file' };
}

const commandsDir = path.dirname(real);
const dotClaudeDir = path.dirname(commandsDir);
const projectPath = path.dirname(dotClaudeDir);
if (isSensitivePath(real)) {
return { ok: false, error: 'path not allowed' };
}

if (path.basename(commandsDir) !== 'commands' || path.basename(dotClaudeDir) !== '.claude') {
return { ok: false, error: 'not inside a project .claude/commands directory' };
const projectPath = resolveOnDisk(path.dirname(dotClaudeDir));
if (!projectPath) {
return { ok: false, error: 'project root not found' };
}

if (typeof isPathAllowed !== 'function' || !isPathAllowed(real)) {
if (typeof isPathAllowed !== 'function' || !isPathAllowed(real) || !isPathAllowed(projectPath)) {
return { ok: false, error: 'path not allowed' };
}

Expand Down
41 changes: 41 additions & 0 deletions scan-md-files.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// scan-md-files.js — non-recursive scan of a directory for the Markdown files
// the Memory/brain tab lists. Electron-free so it can be tested directly.
// See .ai/contexts/ipc-bridge.md, "IPC path-guard inventory".
'use strict';

const fs = require('fs');
const path = require('path');
const { isSensitivePath } = require('./ipc-path-validator');

/**
* Scan `dir` for non-empty `.md` files (non-recursive).
*
* @param {string} dir
* @param {(filePath: string) => boolean} [isAllowed] - caller's allowlist, given the literal path
* @returns {Array<{filename: string, filePath: string, modified: string}>}
*/
function scanMdFiles(dir, isAllowed) {
const results = [];
try {
if (!fs.existsSync(dir)) return results;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
if (!e.name.endsWith('.md')) continue;
if (!e.isFile() && !e.isSymbolicLink()) continue;
const fp = path.join(dir, e.name);
try {
const stat = fs.statSync(fp);
if (!stat.isFile()) continue;
if (isSensitivePath(fp)) continue;
if (isAllowed && !isAllowed(fp)) continue;
const content = fs.readFileSync(fp, 'utf8').trim();
if (content) {
results.push({ filename: e.name, filePath: fp, modified: stat.mtime.toISOString() });
}
} catch { /* unreadable or dangling — skip this entry, keep the rest */ }
}
} catch { /* unreadable directory */ }
return results;
}

module.exports = { scanMdFiles };
Loading
Loading