Skip to content

fix(hooks): scope session-start injection by shortId to prevent cross-cwd leakage#1485

Open
stephan-roolvink wants to merge 1 commit intoaffaan-m:mainfrom
stephan-roolvink:fix/session-start-worktree-isolation
Open

fix(hooks): scope session-start injection by shortId to prevent cross-cwd leakage#1485
stephan-roolvink wants to merge 1 commit intoaffaan-m:mainfrom
stephan-roolvink:fix/session-start-worktree-isolation

Conversation

@stephan-roolvink
Copy link
Copy Markdown

@stephan-roolvink stephan-roolvink commented Apr 17, 2026

Summary

Fixes a cross-worktree / cross-cwd content leak in the SessionStart hook.

Before this patch, scripts/hooks/session-start.js picked the globally-newest *-session.tmp file from ~/.claude/sessions/ and injected it as Previous session summary into the context of every new Claude session, regardless of which cwd/project/worktree the session was for.

Symptom I observed in the wild: a Claude Agent SDK session dispatched for worktree A received worktree B's session summary and began making tool calls as if the task belonged to B — content-level crosstalk across worktrees/projects that share ~/.claude/sessions/.

Type

  • Hook

Fix

  • Import getSessionIdShort from ../lib/utils and parseSessionFilename from ../lib/session-manager
  • Compute the current invocation's shortId (last 8 chars of $CLAUDE_SESSION_ID, else project/worktree basename)
  • Filter recentSessions down to only those whose filename shortId matches the current invocation
  • If no file matches, skip injection entirely rather than fall back to the globally-newest file — a missing summary is far safer than a wrong one
  • Log the mismatch case at log(...) level so operators can still see when recent sessions exist but don't belong to this invocation

No public API change, no new dependencies. Pure filter added to an existing code path.

Testing

New regression test

tests/hooks/session-start-worktree-isolation.test.js — simulates two worktrees (A, B) with session summaries where B's is newer on disk, then runs session-start.js once from each cwd and asserts:

  • Running in worktree A stdout contains TASK-A and does NOT contain TASK-B
  • Running in worktree B stdout contains TASK-B and does NOT contain TASK-A

Before the fix, test 1 fails: worktree A receives worktree B's summary (the globally-newest file), which is exactly the crosstalk bug.

Existing tests updated

Three existing tests in tests/hooks/hooks.test.js previously relied on the globally-newest behaviour. They now pass CLAUDE_SESSION_ID so the invocation's shortId matches the test's session filename and the scenario still exercises the same code path:

  • injects real session content — asserts real content still flows when shortIds match
  • excludes session files older than 7 days — unified shortId across both recent/old files so maxAge filtering is what excludes the old one (the original intent)
  • injects newest session when multiple recent sessions exist — both files share shortId so the newest-by-mtime still wins (the original intent)

No assertions weakened; each test still verifies exactly what it originally verified.

Results

All 204 existing hooks.test.js tests pass.
2 new session-start-worktree-isolation.test.js tests pass.
evaluate-session.test.js and suggest-compact.test.js untouched — still pass.

Checklist

  • Follows format guidelines
  • Tested with Claude Code
  • No sensitive info (API keys, paths)

Summary by cubic

Scopes previous session injection in the SessionStart hook by shortId to stop cross-cwd/worktree leaks. If no matching session exists, nothing is injected.

  • Bug Fixes
    • Use getSessionIdShort and parseSessionFilename to compute the current shortId and filter ~/.claude/sessions/*-session.tmp to only matching files; pick the newest within scope.
    • Skip injection when no files match and log the mismatch instead of falling back to the global newest file.
    • Add tests/hooks/session-start-worktree-isolation.test.js to cover worktree isolation; update three existing tests to set CLAUDE_SESSION_ID. No API or dependency changes.

Written for commit dcb2895. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed session content isolation to prevent previous session summaries from appearing in unrelated contexts.
  • Tests

    • Updated session selection tests to validate scoped behavior.
    • Added regression test to verify session content remains isolated across different project contexts.

…-cwd leakage

The SessionStart hook picked the globally-newest *-session.tmp file from
~/.claude/sessions/ and injected it as "Previous session summary" into the
context of every new Claude session, regardless of which cwd/project/worktree
the session was for.

Symptom observed in the wild: a Claude Agent SDK session dispatched in
worktree A received worktree B's session summary and began making tool
calls as if the task belonged to B — content-level crosstalk across
worktrees/projects sharing a single ~/.claude/sessions directory.

Fix: import getSessionIdShort() and parseSessionFilename(), compute the
current invocation's shortId, and filter recent session files down to only
those whose filename shortId matches. If no file matches, skip injection
entirely rather than fall back to the globally-newest file — a missing
summary is far safer than a wrong one.

Tests:
- New regression test tests/hooks/session-start-worktree-isolation.test.js
  simulates two worktrees (A older, B newer on disk) and asserts each
  invocation gets ONLY its own summary.
- Three existing hooks.test.js tests updated to pass CLAUDE_SESSION_ID
  matching the session filename's shortId so they still exercise their
  scenarios (real content injection, 7-day maxAge boundary, newest-of-many
  selection) under the new scoped behaviour. No assertions weakened.

All 204 existing hook tests + 2 new isolation tests pass.
@ecc-tools
Copy link
Copy Markdown
Contributor

ecc-tools bot commented Apr 17, 2026

ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

The pull request modifies the session-start hook to scope session file injection by the current invocation's shortId (parsed from CLAUDE_SESSION_ID or derived from cwd basename) instead of using the globally newest session file. Tests are updated to validate this scoped behavior, and a new regression test verifies worktree isolation.

Changes

Cohort / File(s) Summary
Session Selection Logic
scripts/hooks/session-start.js
Added shortId-based filtering to scope session file selection. Imports getSessionIdShort and parseSessionFilename utilities; computes currentShortId and filters recentSessions to scopedSessions matching the current shortId; injects session content only when scoped matches exist.
Test Updates
tests/hooks/hooks.test.js
Updated existing tests to validate scoped session injection behavior. Modified "injects real session content", maxAge boundary, and "newest session selection" tests to use matching shortIds in session filenames and set CLAUDE_SESSION_ID environment variable accordingly.
Worktree Isolation Regression Test
tests/hooks/session-start-worktree-isolation.test.js
Added new comprehensive test file that reproduces and validates the fix for cross-worktree session content leakage. Creates isolated $HOME with two separate worktree directories, manipulates file mtimes, spawns child processes, and asserts that session content is scoped to the correct worktree without cross-contamination.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 Hoppy shortIds keep worktrees pure,
No leaking sessions through the burrow door!
Each cwd gets its rightful task,
Scoped and sorted—now that's fast.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: scoping session injection by shortId to prevent cross-cwd leakage, which directly addresses the core problem solved in this PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/session-start-worktree-isolation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/hooks/session-start-worktree-isolation.test.js`:
- Around line 65-84: The test is flaky because session-start.js's
getSessionIdShort() → getProjectName() calls git rev-parse which can walk up
into an unrelated repo under os.tmpdir(); to fix, when spawning the child that
runs session-start.js (from main() using isoHome, worktreeA/worktreeB and
basenameA/basenameB), neutralize git's repository walk-up so rev-parse fails and
the basename fallback is used — for example set a blocking git env for the child
process (e.g. GIT_DIR to a non-existent path or GIT_CEILING_DIRECTORIES to
isoHome) in the spawn/env passed to session-start.js so getProjectName()/git
rev-parse cannot discover an ancestor repo and the test deterministically falls
back to the cwd basename.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d8490633-e670-4823-87f7-67a7228856d8

📥 Commits

Reviewing files that changed from the base of the PR and between 1a50145 and dcb2895.

📒 Files selected for processing (3)
  • scripts/hooks/session-start.js
  • tests/hooks/hooks.test.js
  • tests/hooks/session-start-worktree-isolation.test.js

Comment on lines +65 to +84
async function main() {
const failures = [];

// Isolated $HOME so the test never touches the real ~/.claude
const isoHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-worktree-isolation-'));
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
fs.mkdirSync(sessionsDir, { recursive: true });
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });

// Two fake worktrees as cwds. Each has its own basename which
// getSessionIdShort() will fall back to when CLAUDE_SESSION_ID is unset.
// Basenames must be lowercase alphanumeric+hyphen with length >= 8 to
// match SESSION_FILENAME_REGEX in session-manager.js. Nesting them under
// isoHome keeps the test hermetic and avoids mkdtempSync's mixed-case suffix.
const basenameA = 'worktree-a-test';
const basenameB = 'worktree-b-test';
const worktreeA = path.join(isoHome, basenameA);
const worktreeB = path.join(isoHome, basenameB);
fs.mkdirSync(worktreeA, { recursive: true });
fs.mkdirSync(worktreeB, { recursive: true });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential test flakiness if /tmp (or an ancestor) is inside a git repo.

Both worktree cwds are nested under isoHome in os.tmpdir(). The child process runs session-start.js, which calls getSessionIdShort()getProjectName()git rev-parse --show-toplevel. If any ancestor of the temp directory happens to be a git repo (container images, dev sandboxes where / or $HOME is a repo, or tmpdir mounted inside a worktree), rev-parse will succeed and return that outer repo's basename for both invocations — making both shortIds identical and causing the isolation assertion to either spuriously pass (no leakage because both sides resolve to the same scope and each writes/reads its own basename-named file... wait, actually filenames still embed worktree-a-test / worktree-b-test, so they'd fail to match the outer repo's shortId and the test would fail with "no sessions injected" instead of detecting leakage).

Consider hardening by neutralizing the git walk-up so the basename fallback is deterministic:

🛡️ Proposed hardening
 function runSessionStart({ cwd, env }) {
   return new Promise((resolve, reject) => {
     const proc = spawn('node', [sessionStartScript], {
       cwd,
-      env: { ...process.env, ...env },
+      // GIT_CEILING_DIRECTORIES stops `git rev-parse --show-toplevel` from
+      // walking above the temp worktree and picking up an unrelated outer
+      // repo (e.g. when /tmp or $HOME sits inside a git checkout).
+      env: { ...process.env, GIT_CEILING_DIRECTORIES: cwd, ...env },
       stdio: ['pipe', 'pipe', 'pipe']
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hooks/session-start-worktree-isolation.test.js` around lines 65 - 84,
The test is flaky because session-start.js's getSessionIdShort() →
getProjectName() calls git rev-parse which can walk up into an unrelated repo
under os.tmpdir(); to fix, when spawning the child that runs session-start.js
(from main() using isoHome, worktreeA/worktreeB and basenameA/basenameB),
neutralize git's repository walk-up so rev-parse fails and the basename fallback
is used — for example set a blocking git env for the child process (e.g. GIT_DIR
to a non-existent path or GIT_CEILING_DIRECTORIES to isoHome) in the spawn/env
passed to session-start.js so getProjectName()/git rev-parse cannot discover an
ancestor repo and the test deterministically falls back to the cwd basename.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 17, 2026

Greptile Summary

This PR fixes a real cross-worktree session leak in session-start.js by filtering *-session.tmp files against the current invocation's shortId before injecting them, and adds a solid regression test to prevent the bug from recurring.

  • P1: When CLAUDE_SESSION_ID is unset and the project/git-repo name is fewer than 8 characters (e.g. app, api, web), SESSION_FILENAME_REGEX in session-manager.js requires [a-z0-9-]{8,} and returns null for those filenames — causing the new filter to silently skip them and break session injection, a regression from pre-fix behavior.
  • P2: Old-format session files (YYYY-MM-DD-session.tmp, shortId: 'no-id') are silently excluded with no migration path or distinct log message to differentiate them from a cross-worktree mismatch.

Confidence Score: 4/5

Safe to merge for the common Claude Code use case (CLAUDE_SESSION_ID always provided), but introduces a silent session-injection regression for projects with names shorter than 8 characters when running without CLAUDE_SESSION_ID.

The cross-worktree isolation fix is correct and well-tested. One P1 finding remains: projects with names shorter than 8 characters will silently lose session injection due to SESSION_FILENAME_REGEX's {8,} minimum, a regression from pre-PR behavior. This is a real present defect on the fallback code path, warranting a score of 4 rather than 5.

scripts/hooks/session-start.js — the shortId filter interacts with SESSION_FILENAME_REGEX's 8-char minimum in a way that silently drops sessions for short project names.

Important Files Changed

Filename Overview
scripts/hooks/session-start.js Adds shortId-scoped filter to prevent cross-worktree session leakage; introduces a silent regression for projects with names < 8 characters (SESSION_FILENAME_REGEX minimum) and silently drops all old-format session files.
tests/hooks/session-start-worktree-isolation.test.js New regression test correctly validates cross-worktree isolation using isolated HOME, per-worktree basenames ≥ 8 chars, and mtime manipulation; cleanup is unconditional.
tests/hooks/hooks.test.js Three session-start tests updated to pass CLAUDE_SESSION_ID so filenames match the new shortId filter; assertions unchanged.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[SessionStart hook runs] --> B[findFiles: recent sessions last 7 days]
    B --> C{recentSessions.length > 0?}
    C -- No --> G[Skip injection]
    C -- Yes --> D[getSessionIdShort\nCLAUDE_SESSION_ID slice -8\nor projectName or 'default']
    D --> E[Filter: parseSessionFilename\nshortId === currentShortId]
    E --> F{scopedSessions.length > 0?}
    F -- Yes --> H[Read latest.path\noutput content to stdout]
    F -- No --> I{recentSessions existed?}
    I -- Yes --> J[log: sessions exist but\nnone match shortId - skip]
    I -- No --> G

    style E fill:#f96,stroke:#c00
    E -.->|null if shortId < 8 chars\nor old-format file| K[❌ Silently excluded]
    K -.-> F
Loading

Reviews (1): Last reviewed commit: "fix(hooks): scope session-start injectio..." | Re-trigger Greptile

Comment on lines +44 to +48
const currentShortId = getSessionIdShort();
const scopedSessions = recentSessions.filter(f => {
const parsed = parseSessionFilename(path.basename(f.path));
return parsed && parsed.shortId === currentShortId;
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Short project names silently break session injection

When CLAUDE_SESSION_ID is unset, getSessionIdShort() falls back to the full project/git-repo name (e.g. app, api, web, server). session-end.js writes the file as YYYY-MM-DD-<name>-session.tmp. SESSION_FILENAME_REGEX in session-manager.js requires the shortId segment to be [a-z0-9-]{8,} — it returns null for names shorter than 8 characters. Since this filter skips null results, those session files are silently excluded and injection is broken for any project whose name is < 8 chars. This is a regression: before this PR the globally-newest file was injected regardless of parseability.

A minimal fix is to log a warning for files that are recent but unparseable, so operators can detect the issue:

const scopedSessions = recentSessions.filter(f => {
  const parsed = parseSessionFilename(path.basename(f.path));
  if (!parsed) {
    log(`[SessionStart] Could not parse filename (shortId < 8 chars?): ${path.basename(f.path)}`);
    return false;
  }
  return parsed.shortId === currentShortId;
});

The root fix is to relax SESSION_FILENAME_REGEX to [a-z0-9-]{1,} or to pad short project names in getSessionIdShort() to always produce ≥ 8 characters.

Comment on lines +61 to 63
} else if (recentSessions.length > 0) {
log(`[SessionStart] ${recentSessions.length} recent session(s) exist but none match shortId=${currentShortId}; skipping injection`);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Old-format sessions silently excluded with no migration path

parseSessionFilename returns shortId: 'no-id' for old-format files (YYYY-MM-DD-session.tmp). They will never match any real currentShortId, so users who previously had old-format session files silently lose session continuity after this update. The else if log message ("recent session(s) exist but none match shortId") also fires for this case, making it hard to distinguish a cross-worktree mismatch from a legacy-format issue. Consider differentiating the two cases in the log or documenting the migration trade-off.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/hooks/session-start.js">

<violation number="1" location="scripts/hooks/session-start.js:44">
P2: Filtering by `shortId` here still allows cross-project leakage when `CLAUDE_SESSION_ID` is unset and two worktrees/repos share the same basename.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

// Before this filter, the hook picked the globally-newest session file and
// leaked it cross-worktree — e.g. worktree A received worktree B's summary,
// causing downstream agents to act on another worktree's task.
const currentShortId = getSessionIdShort();
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 17, 2026

Choose a reason for hiding this comment

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

P2: Filtering by shortId here still allows cross-project leakage when CLAUDE_SESSION_ID is unset and two worktrees/repos share the same basename.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/hooks/session-start.js, line 44:

<comment>Filtering by `shortId` here still allows cross-project leakage when `CLAUDE_SESSION_ID` is unset and two worktrees/repos share the same basename.</comment>

<file context>
@@ -33,9 +36,20 @@ async function main() {
+  // Before this filter, the hook picked the globally-newest session file and
+  // leaked it cross-worktree — e.g. worktree A received worktree B's summary,
+  // causing downstream agents to act on another worktree's task.
+  const currentShortId = getSessionIdShort();
+  const scopedSessions = recentSessions.filter(f => {
+    const parsed = parseSessionFilename(path.basename(f.path));
</file context>
Fix with Cubic

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.

1 participant