Skip to content

fix(schedule): a schedule file reached through a symlink is listed and runnable - #294

Merged
devsuitup merged 3 commits into
devsuitup:mainfrom
jbr-sekoia:fix/symlinked-schedule-and-memory-files
Sep 17, 2026
Merged

devsuitup merged 3 commits into
devsuitup:mainfrom
jbr-sekoia:fix/symlinked-schedule-and-memory-files

Conversation

@jbr-sekoia

@jbr-sekoia jbr-sekoia commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

The bug, as it was found

A weekly schedule-audit-memory.md was created through the schedule creator, then moved into a git-versioned dotfiles repo, with the project's .claude/commands/ keeping a symlink to it — so the schedule itself is versioned rather than sitting loose in ~. From that moment the brain tab showed the project as having no schedules at all.

Nothing had actually stopped: scanSchedules lists names and reads through the link, so the cron loop kept firing the task every Monday. The only broken part was the half a user can see. That is the worst shape for this class of bug — the UI says "no schedule" while the task runs unattended, and the disagreement is silent on both sides.

Two readers of these files disagreed with the scheduler about what a schedule file is.

1. The brain tab list — scanMdFiles

get-memories accepted a directory entry on dirent.isFile(). For a symlink that is false however ordinary the file at the other end is, so the entry was dropped with no error, no log, nothing. Same fate for a symlinked ~/.claude/CLAUDE.md or memory note, and for the memory FTS index built from the same list.

It now accepts on what the entry resolves to (fs.statSync, which follows the link), so:

  • a link to a file is listed, at the link's own path — which is what the renderer hands back to read-memory and run-schedule-now;
  • a link to a directory, or a dangling one, still is not.

Moved to scan-md-files.js: main.js needs Electron to load, and this is the part worth testing directly. Per-entry failures are contained while we are here — one unreadable file used to abort the scan of every file after it in the same directory.

2. The play button's path guard — resolveRunNowTarget

Same file, refused with not inside a project .claude/commands directory. The guard checked the .claude/commands shape against the symlink-free path, which for a linked-in schedule points at the dotfiles repo.

The guard now answers each question with the path that question is about:

Check Path it runs on Why
directory shape (.claude/commands parents) requested what makes a file a schedule is the directory that lists it; a versioned configuration is linked in there from a repo elsewhere
filename schedule-*.md requested and resolved the listing directory decides where a schedule may be linked from, never what it may point at
isSensitivePath resolved this handler turns file content into a prompt; it earns the denylist the file panel already has
isAllowedMemoryPath resolved file and resolved project root the spawn cwd is its own thing to allowlist once the two can live on different branches of the filesystem

The second commit exists because the first got that table wrong, in a way worth stating plainly rather than squashing away. Checking the filename only on the requested path left nothing constraining the target: any file the allowlist reaches — anything under ~/.claude, anything in an indexed project — became the prompt of a spawned claude -p by being linked under a schedule-shaped name.

<repo>/.claude/commands/schedule-weekly.md -> ../../../../.claude/.credentials.json

git stores symlinks natively and a relative link survives cloning, so a cloned repository carries this with no local access needed. run-schedule-now reads the target, createScheduleSession writes it verbatim into a session JSONL as a user message, and the spawned CLI sends it as conversation context. Confirmed A/B against main (not a schedule file before, accepted after), and closed by re-asserting the filename on the resolved target.

projectPath is now the project whose .claude/commands lists the schedule, which is also the cwd the run should have had all along; previously a linked-in schedule would have been spawned in the dotfiles repo.

Scope this does not cover

A dotfiles repo that is outside every allowed root — one the user has never opened as a project — is still refused, now with path not allowed rather than not inside a project .claude/commands directory. Widening isAllowedMemoryPath to reach it is a separate decision and not one to make inside a bug fix. The case this does fix is the common one: the configuration repo lives under a directory that is already a known project.

Tests

test/scan-md-files.test.js (new, 6 cases) and 6 new cases in test/run-schedule-now-target.test.js, including the linked-in schedule end to end and each refusal above.

One existing test was found to be proving nothing. "a symlinked commands directory escaping the project is caught by disk resolution" used a containment stub with no equality branch, so it answered false for the project root, refused on the project check, never exercised the check on the file, and asserted only ok === false. Deleting isPathAllowed(real) outright left the whole suite green. The stub now has its equality branch and the test asserts the error string.

Mutation matrix, run against the current tests — each of the five load-bearing checks is now killed by at least one test:

mutant result
drop isPathAllowed(real) 2 tests fail
drop isPathAllowed(projectPath) 1 test fails
drop resolveOnDisk on the project root 1 test fails
drop the filename check on the resolved target 1 test fails
drop isSensitivePath 1 test fails

Full suite on this machine: 1505/1509 pass. The two failures are pre-existing on main here and unrelated:

  • isAllowedMemoryPath: allows files under ~/.claude/ — on this machine ~/.claude/CLAUDE.md really is a symlink out of ~/.claude, and the test passes an empty project list, so nothing allows the target. In the app the real project list covers it.
  • real git: an absolute pathspec outside the repo is refused by git itself — the assertion matches on git's refusal message, which differs for this git version/locale.

Both reproduce on a clean main checkout; neither is touched by this change.

Known, not fixed here

  • On Windows the directory-shape comparison is now case-sensitive against a case-insensitive filesystem (.CLAUDE\commands is refused). In practice the renderer always builds these segments with path.join(projectPath, '.claude', 'commands'), so the spelling is literal.
  • Nothing teaches the scheduler about symlinks, because it never needed teaching — it reads through them already. If a third reader of schedule-*.md is added, resolve the link rather than the dirent; both context docs now say so.

…d runnable

A `schedule-*.md` symlinked into a project's `.claude/commands` from a
git-versioned dotfiles repo vanished from the brain tab, and the cron loop
went on firing it every week. Nothing failed anywhere: the scheduler reads
the file through the link, so the task ran unattended while the UI showed
the project had no schedules at all — the one place a user goes to check.

Two readers disagreed with the scheduler about what a schedule file is.

`scanMdFiles` (the brain tab's list, `get-memories`) accepted a directory
entry on `dirent.isFile()`, which a symlink reports as false however
ordinary the file at the other end is. It now accepts on what the entry
resolves to, so a link to a file is listed and a link to a directory or a
dangling one still is not. Moved to its own module — main.js needs Electron
to load, and this is the part worth testing. Per-entry failures are now
contained too: one unreadable file used to abort the scan of every file
after it in the same directory.

`resolveRunNowTarget` (the play button's path guard) checked the
`.claude/commands` shape against the symlink-free path, so the same file
was refused with "not inside a project .claude/commands directory" — the
directory that *lists* a schedule is what makes it one, and that is the
requested path, not wherever the file is kept. The shape checks move to the
requested path; the allowlist stays on disk-resolved paths and now covers
the project root the run would be spawned in as well as the file, since
with a linked-in schedule the two are no longer the same branch of the
filesystem. A link pointing out of every allowed root is still refused.

Test coverage for both, including the linked-in case end to end.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 5a4f1c2 against main 155fcef, with the path checks replayed against the real modules (run-schedule-now-target.js, scan-md-files.js, ipc-path-validator.js), not the test stubs. The scheduler-side diagnosis and the resolveRunNowTarget reshaping read right; the new tests exercise the production modules and the symlink cases did run on windows-2022 (job log checked, none skipped). Two things block the merge, one is housekeeping.

1. scanMdFiles now reads through links with no sensitivity gate — blocking

scan-md-files.js:36-41 accepts a symlink entry and statSync/readFileSync its target; get-memories (main.js:1234, 1259) feeds every returned path into the FTS index with a plain readFileSync, and I find no isSensitivePath / isAllowedMemoryPath anywhere in that handler. Before this change a symlink dirent failed isFile() and never reached a read, so the absence of a gate never mattered. Now a notes.md → ~/.ssh/id_rsa link under any scanned directory — .claude, .claude/commands, the project root, of every project ever indexed, including a cloned third-party repo added as a project — lands verbatim in the searchable index. Reproduced with exactly that layout: the private-key content came back from the same read get-memories performs.

Ask: gate the resolved target — isSensitivePath at least, isAllowedMemoryPath against the known roots preferably — inside scanMdFiles before accepting a symlink entry (the file-panel handlers at main.js:917-992 show the pattern), and add a test with a link pointing at a sensitive path. One sentence in .ai/contexts/ipc-bridge.md's guard inventory naming that gate.

2. projectPath and realPath are two independent resolutions — say so, and pin it

run-schedule-now-target.js:51 resolves the file, :60 resolves the listing project from the requested path's components; nothing relates the two afterwards. That is the point of the change (a linked-in schedule's bytes live in the dotfiles repo, the run belongs to the listing project), and both still have to pass isPathAllowed, so I am not asking for the pre-PR derivation back. But the consequence is that projA/.claude/commands → projB/.claude/commands (both known projects) now runs projB's schedule content with cwd = projA, auto-approved — pre-PR it ran with cwd = projB. Reproduced. That is a deliberate semantic, not a bug, provided it is written down: one line in .ai/contexts/schedule-runner.md stating that the file and the cwd may legitimately resolve to different allowed roots and that both are allowlisted independently, plus a test pinning the two-known-projects case so the next reader does not "fix" it either way by accident.

3. Comment ceiling

run-schedule-now-target.js:23-37, :56-59 and the scan-md-files.js:9-28 header carry the incident narrative that .ai/contexts/schedule-runner.md already has. Repo rule is a one-line pointer in code (PRs #127/#130); please sweep.

Also checked, no finding: .. segments are collapsed by path.resolve before any check; ELOOP/junction/dangling links fall to resolveOnDisk → null → refused; trailing dot/space fails the anchored filename regex; the /proj vs /proj-evil boundary holds (isWithinRoot appends the separator); a plain non-linked schedule gets a byte-identical projectPath. The two "pre-existing" failures you list both pass on my machine (~/.claude/CLAUDE.md is not a link here), consistent with your machine-dependent explanation.

…just where it is linked from

Checking the `schedule-*.md` filename only on the requested path let the
listing directory decide what the target is. Any file the allowlist reaches —
anything under `~/.claude`, anything in an indexed project — became the prompt
of a spawned `claude -p` by being linked under a schedule-shaped name:

    <repo>/.claude/commands/schedule-weekly.md -> ../../../../.claude/.credentials.json

git stores symlinks natively and a relative link survives cloning, so a cloned
repository carries this without any local access. `run-schedule-now` reads that
target, `createScheduleSession` writes it verbatim into a session JSONL as a
user message, and the spawned CLI sends it as conversation context.

The filename is now checked on the resolved target as well. The directory
shape stays on the requested path, which is the whole point of the change:
where a schedule may be linked *from* is the listing directory's business,
what it may point *at* is not. `isSensitivePath` refuses a resolved target in
a credential location — this handler turns file content into a prompt, so it
earns the denylist the file panel already has.

Also: `resolveOnDisk` failing on the project root reported `file not found`,
which names the wrong path.

Tests: the allowlist on the resolved file was provable-dead — deleting
`isPathAllowed(real)` left the suite green, because the one test that claimed
to pin it used a containment stub with no equality branch, refused on the
project root instead, and asserted only `ok === false`. Five mutants (drop
either allowlist call, drop either disk resolution, drop the target filename
check) are now each caught by a distinct test.
`scanMdFiles` feeds three consumers and only two of them guard their own
reads. `read-memory` and `save-memory` go through `resolveAllowedMemoryPath`;
the FTS indexer in `get-memories` reads each listed file's body with a plain
`readFileSync` and stores it in `search_content`, where it is searchable by
substring. While the list held nothing but regular files inside the directory
being scanned, that asymmetry cost nothing.

Listing what a link resolves to changes that. A `notes.md -> ~/.ssh/id_rsa`
under any scanned directory — `.claude`, `.claude/commands`, the project root,
in any project ever indexed, including a cloned third-party repo — puts the
target's content in the index, while the panel that would display it stays
empty because the reader behind it refuses the same path.

So the scan now answers for what it lists: `isSensitivePath` on every entry,
and the caller's allowlist on top, which `get-memories` supplies as
`isAllowedMemoryPath`. The denylist is not the caller's to choose —
containment in an allowed project root says nothing about a cloned repo's own
`.ssh` or `.env`. A file the app would refuse to open is no longer in the list
at all, which also ends the rows that opened blank and saved with "path not
allowed".

The `stat.isFile()` line was load-bearing and untested: deleting it hangs the
Electron main process forever on a link to a FIFO, since the scan is
synchronous inside an ipcMain.handle. Pinned, along with three other surviving
mutants — the listed name must be the link's and not its target's (the play
button keys on it), the mtime must stay ISO, and `.md` must be a suffix and
not a substring.

`resolveRunNowTarget` resolving the file and the cwd independently means a
schedule linked between two known projects runs one project's content in the
other's directory. That follows from what the change is for; it is now stated
in the schedule-runner context and pinned by a test.
@jbr-sekoia

Copy link
Copy Markdown
Collaborator Author

All three addressed, in 39f5c35 and f7c0c01. One of them changes the guard you reviewed, so it is worth reading before the rest.

Before the three points — the reviewed commit had a hole of its own

Your review is against 5a4f1c2. An internal review pass found, and 39f5c35 closes, a defect in the same reshaping you were reading: moving the schedule-*.md filename check onto the requested path left nothing constraining the target. isAllowedMemoryPath grants all of ~/.claude and every known project root by containment, so any file inside those became the prompt of a spawned claude -p by being linked under a schedule-shaped name:

<repo>/.claude/commands/schedule-weekly.md -> ../../../../.claude/.credentials.json

Confirmed A/B against main (not a schedule file before, accepted at 5a4f1c2). The filename is now checked on the resolved target as well; isSensitivePath applies there too. The directory shape stays on the requested path, which is still the point of the change.

That commit also documents a second finding you would want to know about: isPathAllowed(real) was deletable with the whole suite still green. The test that claimed to pin it used a containment stub with no equality branch, so it answered false for the project root, refused on the project check, never reached the check on the file, and asserted only ok === false. Fixed, and the mutation matrix is in the PR body — five load-bearing checks, each killed by at least one test.

1. scanMdFiles sensitivity gate — done, with the gate split in two

Same defect your reproduction found. One design difference from the ask, deliberate:

  • isSensitivePath is applied inside scanMdFiles unconditionally. It is an invariant, not a policy — containment in an allowed project root says nothing about a cloned repo's own .ssh/.env, which is exactly your "third-party repo added as a project" case.
  • isAllowedMemoryPath is injected by the caller rather than hardcoded. scan-md-files.js would otherwise have to know about getKnownProjectPaths() and stop being testable without Electron. get-memories passes it at all five call sites.

Net effect is what you asked for: a file the app would refuse to open is no longer in the list, so the FTS indexer cannot reach it either. Tests for both halves (a link to .ssh refused under an allow-all predicate; a link outside the allowed root refused by the predicate). Guard inventory line added in .ai/contexts/ipc-bridge.md.

While in there: stat.isFile() turned out to be load-bearing and untested. Deleting it hangs the Electron main process forever on a link to a FIFO — the scan is synchronous inside an ipcMain.handle, so no test timeout can interrupt it either; the regression shows up as a hung suite, and the test comment says so. Three other mutants also survived and are now pinned (the listed name must be the link's, not its target's — memory-workfiles-view.js draws the play button from it; the mtime must stay ISO; .md must be a suffix, not a substring).

2. projectPath / realPath are independent — stated and pinned

Line added to .ai/contexts/schedule-runner.md saying the file and the cwd are resolved and allowlisted separately and may legitimately land in different allowed roots, and a test pins your exact projA -> projB case with both known: content from B, cwd = A, with the assertion messages saying which is which so the next reader sees it is intended.

3. Comment ceiling — swept

run-schedule-now-target.js is down to the file-header pointer; scan-md-files.js keeps a three-line header pointing at the context doc plus the JSDoc signature. The rationale for both lives in .ai/contexts/.

Still not fixed, deliberately

  • A dotfiles repo outside every allowed root stays refused (path not allowed). Widening isAllowedMemoryPath to reach it is a separate decision.
  • The same file reachable under two listed paths (~/.claude/CLAUDE.md and the indexed project it links into) shows as two rows and is indexed twice under two ids. Nothing collides, but editing one row leaves the other stale until reload. De-duplicating on the resolved path means deciding which spelling wins, and seenPaths is per-project with globalFiles outside it entirely — a precedence change, not a bug fix.
  • On Windows the directory-shape comparison is case-sensitive against a case-insensitive filesystem. In practice the renderer always builds those segments with path.join(projectPath, '.claude', 'commands').

Full suite locally: 1509/1513, the two machine-dependent failures you also identified.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 39f5c35 and f7c0c01 against the real modules; CI green on f7c0c01 (9 checks, windows-2022 included, symlink tests ran unskipped).

  1. Gate — resolved. scanMdFiles checks isSensitivePath and the injected isAllowedMemoryPath on every entry before reading, both resolving the target on disk; wired at the five get-memories call sites. Replayed: notes.md → ~/.ssh/id_rsa excluded; a link to a .md inside another known project listed.
  2. Independent resolutions — resolved as documented semantics: doc line in schedule-runner.md, test pinning projA → projB (content from B, cwd A) passes against the module.
  3. Comment sweep — done, both files down to header + JSDoc.

The extra fix in 39f5c35 (filename pattern and isSensitivePath re-checked on the resolved target, so a non-schedule file linked in under a schedule-*.md name cannot become the prompt) is a real hole closed; dropping isPathAllowed(real) now fails the suite, confirmed by mutation.

One thing outside this diff, opened as #295: the project-root CLAUDE.md/GEMINI.md/agents.md block in the same handler still reads with a bare readFileSync and no gate — same class, pre-existing, follow-up.

@devsuitup
devsuitup merged commit 174da3f into devsuitup:main Sep 17, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants