diff --git a/docs/hooks.md b/docs/hooks.md index 8ee53b9..47dfc4b 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -13,7 +13,7 @@ The adapters register these events: The main agent alone maintains canonical task state. Hooks never write or summarize it, print its contents, or infer completed verification. They use the hook event's `cwd`, falling back to the process working directory when no workspace is supplied. Malformed event data does not fall back to another project. An absent state file is a successful no-op. Legacy state under the installed configuration root is not automatically adopted; confirm its task before migrating it. The scripts do not grant permissions, bypass approvals, or replace client permission systems. -When enabled during installation or update, Flashbang runs on `Stop`, after the main agent finishes responding. The default flash lasts about 500 milliseconds. The PowerShell command omits `-WindowStyle Hidden`, which can hide or minimize the shared terminal. The PowerShell hook creates a low-opacity click-through overlay without activating another window when its native GUI APIs are available; unsupported runtimes exit safely. The Bash hook uses Python's `tkinter`, uses `xrandr` for multi-monitor bounds when available, and falls back to `notify-send` when the overlay is unavailable. Registered hooks are best effort: errors do not fail a Claude session. +When enabled during installation or update, Flashbang runs on `Stop`, after the main agent finishes responding. The default flash lasts about 500 milliseconds. The PowerShell command omits `-WindowStyle Hidden`, which can hide or minimize the shared terminal. The PowerShell hook creates a low-opacity click-through overlay without activating another window when its native GUI APIs are available; unsupported runtimes exit safely. Its native DLL cache is optional: a missing `LOCALAPPDATA` environment variable or an unusable cache falls back to in-process compilation instead of suppressing the notification. The Bash hook uses Python's `tkinter`, uses `xrandr` for multi-monitor bounds when available, and falls back to `notify-send` when the overlay is unavailable. Registered hooks are best effort: errors do not fail a Claude session. `Validate-CommandSafety`/`validate-command-safety.sh` and `Invoke-PostChangeVerification`/`invoke-post-change-verification.sh` are unregistered utilities. The safety utilities check command text for selected dangerous patterns; the PowerShell variant also checks some absolute paths. Neither is a complete security boundary. The verification utilities explicitly run the repository build when invoked; they are not an automatic requirement after each change. diff --git a/scripts/tests/test-session-state.py b/scripts/tests/test-session-state.py index 0b07db6..a276dfa 100644 --- a/scripts/tests/test-session-state.py +++ b/scripts/tests/test-session-state.py @@ -207,15 +207,45 @@ class SessionStateHookTests(unittest.TestCase): with self.subTest(shell=shell): environment = dict(os.environ) if shell == 'powershell': - environment['LOCALAPPDATA'] = '' - command = [executable, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', - str(ROOT / 'shared/hooks/flashbang.ps1')] + environment['FLASHBANG_TEST_SCRIPT'] = str(ROOT / 'shared/hooks/flashbang.ps1') + command = [executable, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', + "function Add-Type { throw 'GUI runtime unavailable' }; & $env:FLASHBANG_TEST_SCRIPT"] else: environment['PATH'] = '' command = [executable, str(ROOT / 'shared/hooks/flashbang.sh')] result = subprocess.run(command, env=environment, capture_output=True, text=True, timeout=20) self.assertEqual(result.returncode, 0, result.stderr) + @unittest.skipUnless(os.name == 'nt', 'Windows GUI runtime required') + def test_flashbang_cache_is_optional(self): + executable = shutil.which('powershell') or shutil.which('pwsh') + if not executable: + self.skipTest('PowerShell unavailable') + # Exercise native initialization, stopping at the UI boundary without flashing the screen. + probe = """ +function New-Object { + param([string]$TypeName) + if ($TypeName -eq 'System.Windows.Forms.Form' -and ('Flashbang.Native' -as [type])) { + [Console]::WriteLine('OVERLAY_READY') + } + throw 'Stop at UI boundary' +} +& $env:FLASHBANG_TEST_SCRIPT +""" + with tempfile.TemporaryDirectory(prefix='.flashbang-test-', dir=ROOT) as directory: + blocked = Path(directory) / 'not-a-directory' + blocked.write_text('keep') + for cache in ('', str(blocked), directory): + with self.subTest(cache=cache): + environment = dict(os.environ, LOCALAPPDATA=cache, + FLASHBANG_TEST_SCRIPT=str(ROOT / 'shared/hooks/flashbang.ps1')) + result = subprocess.run([executable, '-NoProfile', '-ExecutionPolicy', 'Bypass', + '-Command', probe], env=environment, capture_output=True, + text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('OVERLAY_READY', result.stdout, 'Flashbang exited before creating the overlay') + self.assertEqual(blocked.read_text(), 'keep') + if __name__ == '__main__': unittest.main() diff --git a/shared/hooks/flashbang.ps1 b/shared/hooks/flashbang.ps1 index e600453..fc5c44b 100644 --- a/shared/hooks/flashbang.ps1 +++ b/shared/hooks/flashbang.ps1 @@ -66,27 +66,27 @@ $nativeSource = @' if (-not ('Flashbang.Native' -as [type])) { # Keyed by host runtime: an assembly emitted by Windows PowerShell (.NET # Framework) is not guaranteed to load under pwsh (.NET), and vice versa. - $cacheDir = Join-Path $env:LOCALAPPDATA 'claude-flashbang' - $runtimeTag = '{0}{1}' -f $PSVersionTable.PSEdition, $PSVersionTable.PSVersion.Major - $cacheDll = Join-Path $cacheDir "Flashbang.Native.$runtimeTag.dll" - $loaded = $false + try { + $cacheDir = Join-Path $env:LOCALAPPDATA 'claude-flashbang' + $runtimeTag = '{0}{1}' -f $PSVersionTable.PSEdition, $PSVersionTable.PSVersion.Major + $cacheDll = Join-Path $cacheDir "Flashbang.Native.$runtimeTag.dll" + $loaded = $false - if (Test-Path -LiteralPath $cacheDll) { - try { Add-Type -Path $cacheDll; $loaded = $true } catch { $loaded = $false } - } + if (Test-Path -LiteralPath $cacheDll) { + try { Add-Type -Path $cacheDll; $loaded = $true } catch { $loaded = $false } + } - if (-not $loaded) { - try { + if (-not $loaded) { if (-not (Test-Path -LiteralPath $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } Add-Type -Namespace 'Flashbang' -Name 'Native' -MemberDefinition $nativeSource ` -OutputAssembly $cacheDll Add-Type -Path $cacheDll - } catch { - # Cache unavailable (locked file, no write access) - compile in-process. - Add-Type -Namespace 'Flashbang' -Name 'Native' -MemberDefinition $nativeSource } + } catch { + # Missing profile environment or unusable cache must not suppress the flash. + Add-Type -Namespace 'Flashbang' -Name 'Native' -MemberDefinition $nativeSource } }