-
Notifications
You must be signed in to change notification settings - Fork 0
Troubleshooting
Symptom → cause → fix for Keyward, with copy-pasteable diagnostics. Most issues fall into one of two buckets: the hook isn't firing at all (detection never runs) or detection runs but the auto-paste doesn't land (a per-platform layer-2 problem). The first thing to know is which bucket you're in — the quick triage below tells you in two commands.
See also: Installation · Configuration · Security-Model · FAQ · Home
- Quick triage
- Symptom → cause → fix table
- Hook not firing
- Detection works but no paste
- Paste lands in the wrong window (focus race)
- Clipboard not restored
- False positives — it triggers when I'm just talking about keys
- False negatives — it missed my key
- Reading ~/.claude/secrets/.last-error
- Testing the engine standalone from the CLI
1. Does the detection layer run? (No display server needed — works everywhere.)
echo '{"user_prompt": "x ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \
| python3 ~/.claude/plugins/keyward/scripts/detect.py-
Outputs a JSON
secretsarray → the engine is fine. Your problem is either hook registration (Hook not firing) or the paste layer (Detection works but no paste). -
command not found/No such file→ the plugin isn't where you think. Re-check the symlink/copy in Installation. -
A Python traceback → your
python3is too old or broken. Runpython3 --version(need 3.9+).
2. Is the hook registered? In a Claude Code session:
/hooks
If there's no UserPromptSubmit → intercept.py line, the engine works but Claude Code isn't calling it → Hook not firing.
3. Did the last paste attempt log an error?
cat ~/.claude/secrets/.last-errorThis file only exists if something in the paste layer failed. It never contains secret values — only error text. See Reading the error log.
| Symptom | Likely cause | Fix |
|---|---|---|
/hooks shows no Keyward entry |
Plugin not installed where Claude Code looks, or session predates install | Re-check symlink in ~/.claude/plugins/keyward; restart Claude Code (hooks load at session start). See Hook not firing. |
| Nothing happens at all on any prompt |
python3 not on PATH, or detect.py import failed → hook fails open (empty JSON, prompt passes through) |
python3 --version; run the standalone detect test. |
| Key saved but prompt not auto-pasted (macOS) | Terminal lacks Accessibility permission | Grant it, fully restart the terminal. See macOS. |
| Saved but not pasted (Linux X11) |
xdotool or clipboard tool missing |
which xdotool xclip xsel; install. See X11. |
| Saved but not pasted (Linux Wayland) | Compositor lacks virtual-keyboard-v1
|
Test wtype; if unsupported, set KEYWARD_DISABLE_PASTE=1. See Wayland. |
| Saved but not pasted (Windows) | Group policy disables SendKeys, or python3 alias missing |
Check .last-error; see Windows. |
| Sanitized text pastes into the wrong app | You changed focus during the ~350 ms paste delay (macOS aborts; others may not) | Don't switch windows after Enter; see focus race. |
| Clipboard has the sanitized prompt afterward, not my old contents | You copied something else during the ~600 ms restore window, or the paste was aborted | See clipboard not restored. |
| Blocked a prompt where I was just discussing a key format | A real-looking value with no placeholder token matched the regex | Prefix the message with /raw . See false positives. |
| Didn't catch my key | Custom/unknown format, or value contained a placeholder token | Use /key NAME=VALUE, or enable gitleaks. See false negatives. |
| Prompt "flashes red" / gets cancelled then a clean one appears |
This is correct behavior — the block decision suppresses the raw prompt and the sanitized one is submitted in its place |
Nothing to fix. |
Detection never runs — no block, no save, no .last-error. Causes, in order of likelihood:
-
Session started before the plugin was installed. Hooks are registered at session start. Quit and relaunch
claude. -
Plugin not in the expected directory. Confirm:
If the symlink is dangling (clone moved/deleted), re-create it per Installation.
ls -l ~/.claude/plugins/keyward # should resolve (symlink → your clone, or a real dir) ls ~/.claude/plugins/keyward/hooks/hooks.json
-
python3not resolvable in the environment Claude Code launched from. The hook command ispython3 ${CLAUDE_PLUGIN_ROOT}/hooks/intercept.py. Ifpython3isn't on PATH, the hook can't start.On Windows, if onlypython3 --version # need 3.9+ command -v python3
pythonexists and notpython3, either install the official python.org build (it providespython3) or disable the conflicting "python3" App Execution Alias under Settings → Apps → Advanced app settings → App execution aliases. -
Claude Code didn't load the plugin. Launch with debug and look for hook-registration warnings:
Then confirm in-session with
claude --debug
/hooks. -
detect.pyis missing or unparseable.intercept.pyfails open if it can't importdetect— it prints{}and lets your prompt through unchanged (by design: better to pass a prompt than to swallow your message). So a brokendetect.pylooks exactly like "hook does nothing." Verify the engine directly with the standalone test.
Why fail-open matters here: almost every error path in
intercept.pyemits empty JSON and lets the original prompt proceed. That's deliberate — Keyward will never block your message because of its own bug. The flip side is that a misconfigured Keyward is silent, so use the standalone tests to tell "working but no secrets" apart from "not running."
The secret was saved (check ls -la ~/.claude/secrets/) but the clean prompt didn't get typed back in. This is a layer-2 (automation) issue — the security layer did its job. The sanitized text is already on your clipboard, so the immediate unblock is always: Cmd/Ctrl+V then Enter. Below is how to fix the automation per platform. Start by reading ~/.claude/secrets/.last-error — automate_paste.py writes the precise failure there.
By far the most common cause. automate_paste.py drives osascript → System Events to send Cmd+V + Return, and macOS silently drops synthetic keystrokes from apps without Accessibility permission.
Fix:
- System Settings → Privacy & Security → Accessibility
- Add and enable your terminal app (Terminal.app, iTerm, Ghostty, Warp, Alacritty, kitty, WezTerm, or Visual Studio Code for its integrated terminal).
- Fully quit and reopen the terminal — the permission is read at process launch, so a running terminal won't pick it up.
Confirm the permission independently of Keyward — run this in the same terminal; if it does nothing or errors, the permission is the problem, not Keyward:
osascript -e 'tell application "System Events" to keystroke "x"'Focus a text field first; if no x appears, fix Accessibility. Also confirm the clipboard tools resolve (they're built in, so this should always pass):
command -v osascript pbcopy pbpasteautomate_paste.py needs xdotool plus one of xclip / xsel. If either piece is absent, the backend reports unavailable and falls back to clipboard-only (it still tries to set the clipboard via any working backend).
which xdotool xclip xsel # need xdotool + (xclip OR xsel)Install the missing piece (sudo apt install xdotool xclip, or the dnf/pacman equivalents in Installation). Then verify synthetic input works at all on your session:
# focus a text field, then:
xdotool key xIf that types nothing, your X server is rejecting synthetic input (rare on X11). Also sanity-check you're really on X11, not Wayland:
echo "$XDG_SESSION_TYPE" # 'x11' here; if 'wayland', see the Wayland sectionKeyward uses wtype + wl-clipboard on Wayland, and wtype only works if the compositor implements virtual-keyboard-v1. Sway and Hyprland do; GNOME/Mutter does not (no fix without an extension); KDE/KWin is version-dependent.
Test directly:
echo "kv test" | wl-copy
sleep 2 && wtype -M ctrl v -m ctrl # focus an editor during the 2s windowIf wtype prints Compositor does not support virtual_keyboard_v1 (or nothing pastes), auto-paste cannot work on your compositor. The clean fallback:
# add to ~/.bashrc / ~/.zshrc so Claude Code inherits it, then restart Claude Code
export KEYWARD_DISABLE_PASTE=1With that set, Keyward skips the doomed keystroke attempt; it still saves the secret and sets the clipboard, and you paste with Ctrl+V + Enter. See Configuration.
Note on the focus check: the Wayland backend has no portable way to read the frontmost window, so it deliberately skips the focus-race guard. That makes the focus race slightly more likely on Wayland than on macOS — don't switch windows right after pressing Enter.
The Windows backend uses PowerShell Set-Clipboard/Get-Clipboard and System.Windows.Forms.SendKeys. Two things can break it:
-
Enterprise group policy disabling
SendKeys/System.Windows.Forms..last-errorwill show a non-zeroSendKeys exit=or an exception. Check with your IT; if it's locked down, you're in clipboard-only mode — paste manually or setKEYWARD_DISABLE_PASTE=1. -
python3not resolvable, so the hook itself never runs (this is really a Hook not firing case). Confirmpython3 --version, and watch for the App Execution Alias conflict noted above.
Verify PowerShell is reachable:
Get-Command pwsh, powershell -ErrorAction SilentlyContinueCause. After the prompt is blocked, automate_paste.py waits ~350 ms (PASTE_DELAY_S) so Claude Code can render the "blocked" state, then sends Ctrl/Cmd+V + Enter to whatever window is focused at that instant. If you alt-tab away during that window, the paste can land elsewhere.
Mitigation built in (macOS). Before pasting, the backend captures the frontmost app name and compares it to the app that was frontmost when the hook fired. If they differ, it aborts the paste, leaves the sanitized text in the clipboard, restores the original clipboard, and logs:
automate_paste.py: frontmost changed 'iTerm2' -> 'Safari' — paste aborted (sanitized text left in clipboard)
Where the guard does not apply:
-
Linux X11 and Windows —
intercept.pyonly computes the frontmost app on macOS; on these platforms it passes an empty expected-app, so the early focus check is skipped. (The X11 backend can read the active window class but isn't given an expected value to compare against.) - Linux Wayland — the backend can't read the frontmost window portably, so the check is skipped there too.
What to do:
- Don't switch windows after pressing Enter until the clean prompt appears (it's well under a second).
- If a paste landed in the wrong app: the sanitized text is on your clipboard — paste it into Claude Code manually (
Cmd/Ctrl+V+ Enter), and your secret is already saved. - For maximum control, set
KEYWARD_DISABLE_PASTE=1and paste every sanitized prompt yourself — no race at all. See Configuration.
Normal behavior. automate_paste.py snapshots your clipboard, writes the sanitized text into it, pastes, waits ~300 ms (RESTORE_DELAY_S), then restores the snapshot. End to end the clipboard is "borrowed" for roughly half a second.
When the original is lost:
-
You copied something else during the borrow window. A
Cmd/Ctrl+Cin that ~600 ms beats the restore — your new copy wins and the snapshot is discarded. There's no recovery; just re-copy what you needed. - The paste was aborted by the focus guard (macOS). In that path the original clipboard is restored, but the sanitized text was never pasted — so you may still see the sanitized prompt if you copied nothing else. Paste it where you intended.
-
set_clipboardfailed at restore time (clipboard tool flaked)..last-errormay note a set-clipboard failure. Re-run your copy.
To sidestep clipboard borrowing entirely, run in manual mode (KEYWARD_DISABLE_PASTE=1). The hook then leaves the sanitized text in a tempfile and never touches your clipboard via the auto-paste path.
Symptom. You're discussing a key format ("what does an sk-ant- token look like?") or pasting logs with dead tokens, and Keyward blocks the prompt.
Why. A value that matches a known pattern and has no placeholder token reads as a real secret. Keyward already ignores any matched value containing (case-insensitive) EXAMPLE, PLACEHOLDER, XXX, YYY, REDACTED, FAKE, DUMMY, ..., or *** — so sk-ant-EXAMPLE... won't trigger. But a realistic, placeholder-free string will.
Fix — bypass detection for that one prompt with /raw:
/raw what's the structure of a ghp_ token vs a github_pat_ one?
/raw strips the prefix and re-submits the remainder with no scanning at all. Use it only when you're certain the message has no real, live secret — /raw disables protection for that prompt entirely. (Mechanically: detect.py returns raw_mode: true on a leading /raw , and intercept.py re-submits the stripped text.)
Confirm whether a given string trips detection without sending it to Claude:
echo '{"user_prompt": "PASTE THE TEXT HERE"}' \
| python3 ~/.claude/plugins/keyward/scripts/detect.pyEmpty secrets array → it would pass through untouched. Non-empty → it would be blocked/sanitized.
Symptom. A real key went through to the model / transcript untouched.
Causes & fixes:
-
Custom or internal token with no known prefix. The regex library covers ~20 well-known providers; a bespoke format won't match. Force-tag it with an explicit marker:
Explicit markers are always treated as secrets (sources
/key prod_db=postgres://u:p@host/db ← slash form (named slot) deploy with KEY:internal_api=mytokenXYZ ← inline named save this KEY=randomvalue123 ← inline default slotexplicit_slash/explicit_named/explicit_default). -
The value contained a placeholder token. If your real key happens to contain
XXX,FAKE, etc., the discussion-safe filter skips it. Rename/rotate, or register it explicitly with/key(the placeholder filter applies to explicit markers too, so pick a name that doesn't embed a placeholder substring in the value). -
Broader coverage wanted. Enable the optional gitleaks pass for high-entropy/generic keys and dozens more providers:
Findings the regex layer missed are saved with
export KEYWARD_USE_GITLEAKS=1 # requires the gitleaks binary on PATH; restart Claude Code
source: gitleaks. Trade-off: ~50–150 ms per prompt and occasional noise on high-entropy strings (use/rawfor those). Details in Configuration.
If a key already slipped through, rotate it. Keyward is defense-in-depth, not a guarantee — see the transcript-ordering caveat in Security-Model.
Reproduce/inspect what would be detected:
# regex-only (default)
echo '{"user_prompt": "tok glpat-xxxxxxxxxxxxxxxxxxxx"}' \
| python3 ~/.claude/plugins/keyward/scripts/detect.py
# with gitleaks pass (must have gitleaks installed)
KEYWARD_USE_GITLEAKS=1 python3 ~/.claude/plugins/keyward/scripts/detect.py \
<<<'{"user_prompt": "tok <some-generic-high-entropy-string>"}'automate_paste.py (and some intercept.py paths) append a timestamped line here on failure. It contains only error text — never secret values, never the sanitized prompt body. The file is created lazily, so its absence means "no paste error has occurred."
cat ~/.claude/secrets/.last-error
# or just the most recent failures:
tail -n 20 ~/.claude/secrets/.last-errorHow to read common lines:
| Log line (excerpt) | Meaning | Action |
|---|---|---|
macOS osascript exit=1: ... |
System Events keystroke rejected | Grant Accessibility, restart terminal — see macOS |
no usable backend (platform=...) |
Required tools not installed for the detected platform | Install per-platform deps — see Installation |
frontmost changed 'A' -> 'B' — paste aborted |
You switched focus mid-paste (macOS guard) | Don't switch windows after Enter — see focus race |
linux-x11 xdotool ... exit=... |
xdotool ran but the key event failed |
Check the X session accepts synthetic input |
linux-wayland wtype ... exit=... |
Compositor rejected the virtual keystroke | Compositor unsupported — set KEYWARD_DISABLE_PASTE=1
|
windows SendKeys exit=... |
PowerShell SendKeys blocked or errored |
Check group policy — see Windows |
failed to set clipboard |
Clipboard tool failed to write | Reinstall/verify the clipboard tool for your platform |
Clear it any time (it's just a log):
rm -f ~/.claude/secrets/.last-errorAll of these work outside Claude Code and don't touch the network. Use absolute paths under ~/.claude/plugins/keyward/ (your install location).
1. Detection only — fastest signal that the engine works. Prints what would be saved/sanitized; no files written, no paste:
echo '{"user_prompt": "deploy ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \
| python3 ~/.claude/plugins/keyward/scripts/detect.pyExpected: a secrets array with one github_pat_classic entry, source: "regex", raw_mode: false.
2. The orchestrator end-to-end, without triggering a real paste. KEYWARD_DISABLE_PASTE=1 makes intercept.py skip the detached automate_paste.py spawn, so this does write the secret to ~/.claude/secrets/ and emit the block JSON, but won't move your mouse/keyboard or clipboard:
KEYWARD_DISABLE_PASTE=1 python3 ~/.claude/plugins/keyward/hooks/intercept.py \
<<<'{"user_prompt": "deploy ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'Expected stdout: {"decision": "block", "reason": "[keyward] Intercepted 1 secret(s): github_pat_classic [regex]. ...", "suppressOriginalPrompt": true}. Afterward, ls -la ~/.claude/secrets/ shows github_pat_classic.txt (chmod 600). Use a throwaway value — this really does save it.
Important: the env var must be set for the
python3process.KEYWARD_DISABLE_PASTE=1 python3 ... <<<'...'(above) is correct because the assignment prefixes the command that's actually run. A form likeKEYWARD_DISABLE_PASTE=1 echo '...' | python3 ...does not work — the var would apply toecho, not to the pipedpython3, and the real paste would fire. Prefer the here-string form shown above, orexport KEYWARD_DISABLE_PASTE=1first.
3. /raw bypass — confirm raw mode short-circuits detection:
echo '{"user_prompt": "/raw show me a ghp_ token format"}' \
| python3 ~/.claude/plugins/keyward/scripts/detect.py
# → {"secrets": [], "raw_mode": true}4. The paste backend in isolation — exercise only automate_paste.py against a tempfile of harmless text. This will really paste into whatever's focused, so point it at a scratch editor:
printf 'keyward paste backend test\n' > /tmp/kv-test.txt
python3 ~/.claude/plugins/keyward/scripts/automate_paste.py /tmp/kv-test.txt
# focus a scratch text field immediately; check ~/.claude/secrets/.last-error if nothing pastes5. List / remove saved slots (names, sizes, perms, mtimes — never values):
python3 ~/.claude/plugins/keyward/scripts/manage_secrets.py list
python3 ~/.claude/plugins/keyward/scripts/manage_secrets.py remove github_pat_classicThese are exactly what the /key-list and /key-rm slash commands invoke.
6. The full test suite (35 stdlib unittest cases; gitleaks cases self-skip if the binary is absent):
python3 -m unittest discover -s ~/keyward/tests -p 'test_*.py' -vStill stuck? Open an issue at github.com/AlbeMiglio/keyward/issues with: your OS + (on Linux) echo $XDG_SESSION_TYPE, the output of the standalone detect test, the relevant lines from ~/.claude/secrets/.last-error, and whether the secret file was created in ~/.claude/secrets/. That distinguishes a detection problem from a paste problem immediately.
Getting started
Reference
Project