fix: port hook session and dashboard safety fixes

Ports suggest-compact session_id isolation and dashboard terminal/document launch safety onto current main.
This commit is contained in:
Affaan Mustafa 2026-05-11 02:53:28 -04:00 committed by GitHub
parent 27508842b1
commit 1abc3fb381
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 111 additions and 26 deletions

View File

@ -8,10 +8,11 @@ import tkinter as tk
from tkinter import ttk, scrolledtext, messagebox from tkinter import ttk, scrolledtext, messagebox
import os import os
import json import json
import subprocess from pathlib import Path
from typing import Dict, List, Optional 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 # DATA LOADERS - Load ECC data from the project
@ -793,27 +794,31 @@ Project: github.com/affaan-m/everything-claude-code"""
def open_terminal(self): def open_terminal(self):
"""Open terminal at project path""" """Open terminal at project path"""
path = self.path_entry.get() path = os.path.realpath(self.path_entry.get())
argv, kwargs = build_terminal_launch(path) try:
subprocess.Popen(argv, **kwargs) 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): def open_readme(self):
"""Open README in default browser/reader""" """Open README in default browser/reader"""
import subprocess self._open_project_doc('README.md')
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")
def open_agents(self): def open_agents(self):
"""Open AGENTS.md""" """Open AGENTS.md"""
import subprocess self._open_project_doc('AGENTS.md')
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")
def refresh_data(self): def refresh_data(self):
"""Refresh all data""" """Refresh all data"""

View File

@ -18,14 +18,30 @@ const path = require('path');
const { const {
getTempDir, getTempDir,
writeFile, writeFile,
readStdinJson,
log log
} = require('../lib/utils'); } = 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() { async function main() {
// Track tool call count (increment in a temp file) // Track tool call count (increment in a temp file)
// Use a session-specific counter file based on session ID from environment // Use a session-specific counter file based on session ID from stdin JSON,
// or parent PID as fallback // legacy env var, or 'default' as fallback.
const sessionId = (process.env.CLAUDE_SESSION_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '') || 'default'; const rawSessionId = await resolveSessionId();
const sessionId = rawSessionId.replace(/[^a-zA-Z0-9_-]/g, '') || 'default';
const counterFile = path.join(getTempDir(), `claude-tool-count-${sessionId}`); const counterFile = path.join(getTempDir(), `claude-tool-count-${sessionId}`);
const rawThreshold = parseInt(process.env.COMPACT_THRESHOLD || '50', 10); const rawThreshold = parseInt(process.env.COMPACT_THRESHOLD || '50', 10);
const threshold = Number.isFinite(rawThreshold) && rawThreshold > 0 && rawThreshold <= 10000 const threshold = Number.isFinite(rawThreshold) && rawThreshold > 0 && rawThreshold <= 10000

View File

@ -45,7 +45,7 @@ def build_terminal_launch(
if resolved_os_name == 'nt': if resolved_os_name == 'nt':
creationflags = getattr(subprocess, 'CREATE_NEW_CONSOLE', 0) creationflags = getattr(subprocess, 'CREATE_NEW_CONSOLE', 0)
return ( return (
['cmd.exe', '/k', 'cd', '/d', path], ['cmd.exe'],
{ {
'cwd': path, 'cwd': path,
'creationflags': creationflags, 'creationflags': creationflags,
@ -59,3 +59,12 @@ def build_terminal_launch(
['x-terminal-emulator', '-e', 'bash', '-lc', 'cd -- "$1"; exec bash', 'bash', path], ['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

View File

@ -1178,6 +1178,47 @@ async function runTests() {
passed++; passed++;
else failed++; 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 // evaluate-session.js tests
console.log('\nevaluate-session.js:'); console.log('\nevaluate-session.js:');

View File

@ -79,13 +79,27 @@ argv, kwargs = module.build_terminal_launch(r'C:\\\\Users\\\\user\\\\proj & del
print(json.dumps({'argv': argv, 'kwargs': kwargs})) print(json.dumps({'argv': argv, 'kwargs': kwargs}))
`); `);
const parsed = JSON.parse(output); const parsed = JSON.parse(output);
assert.deepStrictEqual(parsed.argv.slice(0, 4), ['cmd.exe', '/k', 'cd', '/d']); assert.deepStrictEqual(parsed.argv, ['cmd.exe']);
assert.strictEqual(parsed.argv[4], parsed.kwargs.cwd); assert.ok(parsed.kwargs.cwd.includes('proj & del'), 'path should remain a literal cwd value');
assert.ok(parsed.argv[4].includes('proj & del'), 'path should remain a literal argv entry'); assert.ok(parsed.kwargs.cwd.includes('C:'), 'windows drive prefix should be preserved');
assert.ok(parsed.argv[4].includes('C:'), 'windows drive prefix should be preserved');
assert.ok(Object.prototype.hasOwnProperty.call(parsed.kwargs, 'creationflags')); assert.ok(Object.prototype.hasOwnProperty.call(parsed.kwargs, 'creationflags'));
})) passed++; else failed++; })) 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', () => { if (test('maximize_window falls back to Linux zoom attribute when zoomed state is unsupported', () => {
const output = runPython(` const output = runPython(`
import importlib.util, json import importlib.util, json