Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ Full sourcing notes, including what was left out and why: [docs/provenance.md](d
| `new-file-callout` | A new untracked file at the repo root or under `scripts/`: the reply must name it and say why. |
| `agent-relay-attribution` | Advisory: facts relayed from a subagent's report must say so or be re-verified. |
| `scratchpad-collision` | Two agents writing the same scratchpad file within ten minutes: use a uniquely named file. |
| `ui-input-guard` | Synthetic keystrokes, clicks, or screen recording aimed at the user's own session: blocked unless a hands-off window is open, the screen is unlocked, and the user is idle. |
| `hook-freshness` | Advisory: the catstack checkout behind `~/.claude/hooks` is off `main` or behind `origin/main`, so merged hook fixes are not live on this machine. |
| `auto-pr` | catstack itself changed: tell the agent to open a PR, no request needed. |
| `cat-mode-default` | Every investigation or execution prompt, and every subagent prompt sent through the Agent tool: apply `cat-mode` without typing `/cat-mode`. Off unless `CATSTACK_CAT_MODE_DEFAULT=1` (env or `.env`; see `engine/hooks/cat-mode-default/README.md`). |
Expand Down
1 change: 1 addition & 0 deletions docs/ecosystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ again.
| `new-file-callout` | hook |
| `agent-relay-attribution` | hook (advisory) |
| `scratchpad-collision` | hook |
| `ui-input-guard` | hook |
| `hook-freshness` | hook (advisory) |
| `engine/CLAUDE.core.md` | global hand-written Claude rules |
| `scripts/`, `always-on/`, `cursor/rules/` (repo root), root `install.sh` | runtime (engine-owned entrypoints at root for CI) |
Expand Down
53 changes: 53 additions & 0 deletions engine/hooks/ui-input-guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# ui-input-guard

PreToolUse hook (Bash): never drive the user's own keyboard, mouse, or screen
uninvited. Synthetic input acts on the session the user is sitting in — typed
into the wrong window it sends real messages, trips real shortcuts, and lands
in the lock screen; a screen recording captures whatever they have open.

Blocked mechanisms: AppleScript `System Events` with `keystroke`, `key code`,
or `click at`; `cliclick`; `xdotool`; `screencapture -V`; `ffmpeg` capturing
an `avfoundation` screen device. A command that runs a local script is scanned
through that script's contents, because the wrapper hides what it does, and
shell variables in the path are resolved first (`S=/tmp/run; $S/drive.sh`).

Scripts are streamed in chunks rather than skipped for being large: a silent
skip is an unchecked file reported as clean. Past an 8 MB ceiling, or on a
read error, the command is refused with the path and the reason instead,
because a guard that cannot check does not assume safe. The escape is the
same hands-off marker, or splitting the input-driving part into a file that
can be read.

Allowed when all three hold:

1. A hands-off window is open — `touch /tmp/.ui-input-window` (override with
`UI_INPUT_WINDOW_FILE`), younger than 30 minutes.
2. The screen is not locked (macOS `CGSSessionScreenIsLocked`).
3. The user has been idle at least 10 seconds (macOS `HIDIdleTime`).

Stays silent on the neighbours that only observe or author: `open` on a deep
link, an AppleScript geometry read, a still `screencapture`, `ffmpeg`
transcoding a file, a `cat > script <<EOF` heredoc that writes a driver, and
read-only pipelines whose search pattern happens to contain the words. Code
handed to a non-shell interpreter, as a heredoc or through `-c`/`-e`, counts
as data, so input driven from inside a Python or Node program is a known gap;
`osascript -e` is not stripped, since there the words are the mechanism.

Mechanical half of the live-demo rules in `engine/CLAUDE.core.md` and the UI
testing section of `corpus/skills/cat-mode/SKILL.md`. Probe errors and
non-macOS hosts fail open on lock and idle; a missing marker still blocks.

## Files

- `detect.py` — mechanism patterns, heredoc and read-only stripping, script
following, lock/idle probes, `decide()`.
- `claude_pretooluse_check.py` — Claude PreToolUse entrypoint.
- `claude.hook.json` / `install_claude_hook.py` — settings.json merge (idempotent).
- `tests/fixtures/commands_{fire,silent}.json` — sanitized real commands.
- `tests/test_hooks.py`

## Env

| Var | Effect |
|-----|--------|
| `UI_INPUT_WINDOW_FILE` | Hands-off marker path (default `/tmp/.ui-input-window`). |
16 changes: 16 additions & 0 deletions engine/hooks/ui-input-guard/claude.hook.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 $HOME/.claude/hooks/ui-input-guard/claude_pretooluse_check.py",
"timeout": 10
}
]
}
]
}
}
32 changes: 32 additions & 0 deletions engine/hooks/ui-input-guard/claude_pretooluse_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Claude Code PreToolUse hook (Bash): block synthetic keyboard, mouse, and
screen-capture commands aimed at the user's live session unless a hands-off
window is open, the screen is unlocked, and the user is idle. Exit 2 blocks;
any error fails open.
"""
from __future__ import annotations

import json
import sys

from detect import decide


def main() -> None:
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, OSError):
return
try:
message = decide(payload if isinstance(payload, dict) else {})
except Exception as exc:
sys.stderr.write(f"ui-input-guard: detector error, allowing this call: {exc!r}\n")
return
if not message:
return
sys.stderr.write(message + "\n")
sys.exit(2)


if __name__ == "__main__":
main()
Loading
Loading