From 1abc3fb381bce27c0fae58960c3fd58d682248d0 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 11 May 2026 02:53:28 -0400 Subject: [PATCH] fix: port hook session and dashboard safety fixes Ports suggest-compact session_id isolation and dashboard terminal/document launch safety onto current main. --- ecc_dashboard.py | 41 ++++++++++++++++------------ scripts/hooks/suggest-compact.js | 22 +++++++++++++-- scripts/lib/ecc_dashboard_runtime.py | 11 +++++++- tests/hooks/hooks.test.js | 41 ++++++++++++++++++++++++++++ tests/scripts/ecc-dashboard.test.js | 22 ++++++++++++--- 5 files changed, 111 insertions(+), 26 deletions(-) diff --git a/ecc_dashboard.py b/ecc_dashboard.py index dfe54ea0..6c0923ca 100644 --- a/ecc_dashboard.py +++ b/ecc_dashboard.py @@ -8,10 +8,11 @@ import tkinter as tk from tkinter import ttk, scrolledtext, messagebox import os import json -import subprocess +from pathlib import Path from typing import Dict, List, Optional +import webbrowser -from scripts.lib.ecc_dashboard_runtime import build_terminal_launch, maximize_window +from scripts.lib.ecc_dashboard_runtime import launch_terminal, maximize_window # ============================================================================ # DATA LOADERS - Load ECC data from the project @@ -793,27 +794,31 @@ Project: github.com/affaan-m/everything-claude-code""" def open_terminal(self): """Open terminal at project path""" - path = self.path_entry.get() - argv, kwargs = build_terminal_launch(path) - subprocess.Popen(argv, **kwargs) - + path = os.path.realpath(self.path_entry.get()) + try: + launch_terminal(path) + except Exception as exc: + messagebox.showerror("Error", f"Could not open terminal: {exc}") + + def _open_project_doc(self, filename: str) -> None: + """Open a project document safely, constrained to the project directory.""" + base = os.path.realpath(self.path_entry.get()) + target = os.path.realpath(os.path.join(base, filename)) + if os.path.commonpath([base, target]) != base: + messagebox.showerror("Error", "Access denied: path is outside the project directory") + return + if os.path.exists(target): + webbrowser.open(Path(target).as_uri()) + else: + messagebox.showerror("Error", f"{filename} not found") + def open_readme(self): """Open README in default browser/reader""" - import subprocess - path = os.path.join(self.path_entry.get(), 'README.md') - if os.path.exists(path): - subprocess.Popen(['xdg-open' if os.name != 'nt' else 'start', path]) - else: - messagebox.showerror("Error", "README.md not found") + self._open_project_doc('README.md') def open_agents(self): """Open AGENTS.md""" - import subprocess - path = os.path.join(self.path_entry.get(), 'AGENTS.md') - if os.path.exists(path): - subprocess.Popen(['xdg-open' if os.name != 'nt' else 'start', path]) - else: - messagebox.showerror("Error", "AGENTS.md not found") + self._open_project_doc('AGENTS.md') def refresh_data(self): """Refresh all data""" diff --git a/scripts/hooks/suggest-compact.js b/scripts/hooks/suggest-compact.js index 7e07549a..be3f2e79 100644 --- a/scripts/hooks/suggest-compact.js +++ b/scripts/hooks/suggest-compact.js @@ -18,14 +18,30 @@ const path = require('path'); const { getTempDir, writeFile, + readStdinJson, log } = require('../lib/utils'); +async function resolveSessionId() { + // Claude Code passes hook input via stdin JSON; session_id is the + // canonical field. Fall back to the legacy env var, then 'default'. + try { + const input = await readStdinJson({ timeoutMs: 1000 }); + if (input && typeof input.session_id === 'string' && input.session_id) { + return input.session_id; + } + } catch { + /* fall through to env */ + } + return process.env.CLAUDE_SESSION_ID || 'default'; +} + async function main() { // Track tool call count (increment in a temp file) - // Use a session-specific counter file based on session ID from environment - // or parent PID as fallback - const sessionId = (process.env.CLAUDE_SESSION_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '') || 'default'; + // Use a session-specific counter file based on session ID from stdin JSON, + // legacy env var, or 'default' as fallback. + const rawSessionId = await resolveSessionId(); + const sessionId = rawSessionId.replace(/[^a-zA-Z0-9_-]/g, '') || 'default'; const counterFile = path.join(getTempDir(), `claude-tool-count-${sessionId}`); const rawThreshold = parseInt(process.env.COMPACT_THRESHOLD || '50', 10); const threshold = Number.isFinite(rawThreshold) && rawThreshold > 0 && rawThreshold <= 10000 diff --git a/scripts/lib/ecc_dashboard_runtime.py b/scripts/lib/ecc_dashboard_runtime.py index f882c919..54955246 100644 --- a/scripts/lib/ecc_dashboard_runtime.py +++ b/scripts/lib/ecc_dashboard_runtime.py @@ -45,7 +45,7 @@ def build_terminal_launch( if resolved_os_name == 'nt': creationflags = getattr(subprocess, 'CREATE_NEW_CONSOLE', 0) return ( - ['cmd.exe', '/k', 'cd', '/d', path], + ['cmd.exe'], { 'cwd': path, 'creationflags': creationflags, @@ -59,3 +59,12 @@ def build_terminal_launch( ['x-terminal-emulator', '-e', 'bash', '-lc', 'cd -- "$1"; exec bash', 'bash', path], {}, ) + + +def launch_terminal(path: str) -> None: + """Open a terminal at the given path after validating the target directory.""" + canonical = os.path.realpath(path) + if not os.path.isdir(canonical): + raise ValueError(f"Path is not a valid directory: {canonical!r}") + argv, kwargs = build_terminal_launch(canonical) + subprocess.Popen(argv, **kwargs) # noqa: S603 - list argv, no shell=True, path validated above diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index d38e2f5a..c5a6554e 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -1178,6 +1178,47 @@ async function runTests() { passed++; else failed++; + if ( + await asyncTest('reads session_id from stdin JSON (Claude Code wire format)', async () => { + const sessionId = 'test-stdin-' + Date.now(); + const stdinJson = JSON.stringify({ session_id: sessionId, tool_name: 'Edit' }); + + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), stdinJson, {}); + assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); + + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + assert.ok(fs.existsSync(counterFile), `Counter file should be created from stdin session_id at ${counterFile}`); + const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); + assert.strictEqual(count, 1, `Counter should be 1, got ${count}`); + + fs.unlinkSync(counterFile); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('stdin session_id takes precedence over env CLAUDE_SESSION_ID', async () => { + const stdinSession = 'stdin-wins-' + Date.now(); + const envSession = 'env-loses-' + Date.now(); + const stdinJson = JSON.stringify({ session_id: stdinSession }); + + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), stdinJson, { + CLAUDE_SESSION_ID: envSession + }); + assert.strictEqual(result.code, 0); + + const stdinCounter = path.join(os.tmpdir(), `claude-tool-count-${stdinSession}`); + const envCounter = path.join(os.tmpdir(), `claude-tool-count-${envSession}`); + assert.ok(fs.existsSync(stdinCounter), 'Stdin session counter must exist'); + assert.ok(!fs.existsSync(envCounter), 'Env session counter must NOT exist when stdin provides session_id'); + + fs.unlinkSync(stdinCounter); + }) + ) + passed++; + else failed++; + // evaluate-session.js tests console.log('\nevaluate-session.js:'); diff --git a/tests/scripts/ecc-dashboard.test.js b/tests/scripts/ecc-dashboard.test.js index 2eed98b9..d30146c3 100644 --- a/tests/scripts/ecc-dashboard.test.js +++ b/tests/scripts/ecc-dashboard.test.js @@ -79,13 +79,27 @@ argv, kwargs = module.build_terminal_launch(r'C:\\\\Users\\\\user\\\\proj & del print(json.dumps({'argv': argv, 'kwargs': kwargs})) `); const parsed = JSON.parse(output); - assert.deepStrictEqual(parsed.argv.slice(0, 4), ['cmd.exe', '/k', 'cd', '/d']); - assert.strictEqual(parsed.argv[4], parsed.kwargs.cwd); - assert.ok(parsed.argv[4].includes('proj & del'), 'path should remain a literal argv entry'); - assert.ok(parsed.argv[4].includes('C:'), 'windows drive prefix should be preserved'); + assert.deepStrictEqual(parsed.argv, ['cmd.exe']); + assert.ok(parsed.kwargs.cwd.includes('proj & del'), 'path should remain a literal cwd value'); + assert.ok(parsed.kwargs.cwd.includes('C:'), 'windows drive prefix should be preserved'); assert.ok(Object.prototype.hasOwnProperty.call(parsed.kwargs, 'creationflags')); })) passed++; else failed++; + if (test('launch_terminal rejects missing or non-directory paths', () => { + const output = runPython(` +import importlib.util, json +spec = importlib.util.spec_from_file_location("ecc_dashboard_runtime", r"""${runtimeHelpersPath}""") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +try: + module.launch_terminal('/definitely/not/a/real/ecc/path') +except ValueError as exc: + print(json.dumps({'error': str(exc)})) +`); + const parsed = JSON.parse(output); + assert.ok(parsed.error.includes('Path is not a valid directory')); + })) passed++; else failed++; + if (test('maximize_window falls back to Linux zoom attribute when zoomed state is unsupported', () => { const output = runPython(` import importlib.util, json