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
6 changes: 3 additions & 3 deletions corpus/skills/cat-mode/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,9 @@ agent switch, or resubmit is a fix, and none comes before the repro.

**An interruption or stuck state gets instrument-level proof before a fix, and the fix goes to a subagent.** A poll loop not converging, a process not responding as expected, a restart that doesn't complete — treat this as its own investigation, not something to guess through inline. Gather real evidence first (the target's own logs, `ps -o stat,wchan`, a live query) before naming a cause, then delegate the actual fix to a subagent rather than hand-patching it in the main thread. A DO1 restart once looked hung on a stale PID; the owner's own log showed the real mechanism in two lines — `received SIGTERM, shutting down gracefully` followed 30s later by `process survived SIGTERM for 30000ms after worker stop; restarting worker` — a per-worker watchdog resurrecting mid-shutdown under real task load, not a hang.

**A factual or technical claim gets a real repro script, not a history search.**
Judging an old comment or a "probably confabulated" suspicion needs an actual attempt under the claimed conditions, not a `git log` sweep. No citation means
"never verified," not "false." A live repro proved a dismissed "yauzl hangs" comment was real on the pinned versions.
**UI testing must not disrupt the user's own session.** Prove a UI or surface change somewhere disposable — a test channel or workspace, a throwaway profile, a second display, a VM, a headless run. Driving the user's real keyboard, mouse, or screen is a last resort needing an explicit hands-off window first: state the acceptance test in one line, get the yes, `touch /tmp/.ui-input-window`, and remove it when the window closes; a PreToolUse hook (`engine/hooks/ui-input-guard/`) blocks synthetic input and screen recording while no window is open, the screen is locked, or the user is still typing. Stop at the first sign the session is theirs again (idle time drops, the frontmost app changes, the screen locks), and leave no residue: undo stray messages, pins, or reactions, or say what was left behind.

**A factual or technical claim gets a real repro script, not a history search.** Judging an old comment or a "probably confabulated" suspicion needs an actual attempt under the claimed conditions, not a `git log` sweep. No citation means "never verified," not "false."

**Unhedged root-cause or fix claims about live system behavior need
instrument-level proof in the same message, or `UNVERIFIED:`.** The gate is the claim type ("this is why it's slow," "this is the bug"), not a
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
2 changes: 2 additions & 0 deletions engine/CLAUDE.core.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ These override brevity. If proof makes a message longer, the message gets longer
- If a test was skipped, timed out, or I ran a subset, say exactly which and why — never let a partial run stand in for a full one.
- If the user asks "did you verify X?", answer yes or no first, then show the evidence or admit there is none. Do not re-argue the original claim.
- When I catch myself about to assert something I did not observe, stop and run the check instead of writing the sentence.
- **A check that could not run is not a pass.** When a guard, gate, scan, or query meets input it cannot read — a file past a size cap, an unresolved path, a field a projection omits, a probe that errored — it says so or refuses. It never returns clean. Give such a check three outcomes (hit, clean, unchecked), not two, and pin the third with a test; whether it then fails open or closed is a per-check decision that gets written down. Saltzer and Schroeder put the burden the same way in "Basic Principles of Information Protection" (1975): base access decisions on permission rather than exclusion, so the default is lack of access and the scheme names the conditions under which access is permitted (https://web.mit.edu/Saltzer/www/publications/protection/Basic.html).
- The same rule applies to claims about the conversation itself, not just about code: "I ignored/missed/forgot X" is a claim that needs evidence too. Grep the actual transcript for the instruction before saying that. If nothing turns up, say "I don't have a record of that instruction in this session" — not self-blaming language for something that was never said.
- A Grep or name hit is not a check. Do not cite a file, line, or "the bug is X" until this turn's Read or command output is in the same message. If two files could match, Read both. Prefix `UNVERIFIED:` until then. Saying "my earlier check was wrong" means the claim went out before the check — that is a process failure, not a polite recovery.

Expand All @@ -37,6 +38,7 @@ These override brevity. If proof makes a message longer, the message gets longer
- Before inviting me to test: run one full end-to-end machine-verified rehearsal of the exact flow I will perform. Pieces verified separately don't count as ready. Never say "go" on assembly alone.
- Before the live test starts, restate the acceptance test in one sentence and get my yes ("the test is: you speak, and X happens"). I should never have to write it myself in caps.
- Once I'm testing: freeze the demo surface. No edits, relaunches, or cosmetic changes to the thing I'm looking at unless I asked or the test is failing. Same session: an unrequested layout edit during the test window corrupted the demo page. Mechanically: when the live window opens, write the demo-surface paths (one absolute path, `dir/` prefix, or glob per line) to `/tmp/.demo-freeze`, and delete the file when the window ends — a PreToolUse hook (`engine/hooks/demo-freeze/`) blocks edits to matching paths while it exists (auto-expires after 2h).
- Prove UI work somewhere disposable (test channel, throwaway profile, second display, VM, headless run) instead of my live session. Driving my real keyboard, mouse, or screen needs an explicit hands-off window: state the acceptance test in one line, get my yes, then `touch /tmp/.ui-input-window` and delete it when the window ends — a PreToolUse hook (`engine/hooks/ui-input-guard/`) blocks synthetic input and screen recording while no window is open, the screen is locked, or I am still typing. Undo stray messages or reactions the run created, or say what was left.
- Every message during a live window ends with exactly one action for me, or "nothing needed from you for ~N minutes". Never leave me waiting without a named next step.
- If the deliverable is a same-day demo, plan the demo path first — the smallest end-to-end visible slice. Product-grade extras (settings UIs, multi-platform parity, test suites) come only after the demo runs.

Expand Down
39 changes: 39 additions & 0 deletions engine/hooks/scope-lock/tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,45 @@ def run_main(main, payload: dict) -> tuple[int, str, str]:
return code, out.getvalue(), err.getvalue()


class TestUnreadableInput(unittest.TestCase):
"""Pin what happens to input the detector cannot read.

Two files feed a decision here, and they resolve in opposite directions
on purpose: a corrupt state file means no lock is in force, while an
unreadable transcript means no scope contract was recorded, so a lock
already in force is not released by a file that could not be read.
"""

def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.saved_state_dir = detect.STATE_DIR
detect.STATE_DIR = self.tmp.name

def tearDown(self) -> None:
detect.STATE_DIR = self.saved_state_dir
self.tmp.cleanup()

def test_fails_open_when_the_state_file_is_malformed(self):
payload = {"session_id": "session-corrupt"}
with open(detect.state_path(payload), "w", encoding="utf-8") as handle:
handle.write("{not json at all")
self.assertEqual(detect.load_state(payload), {})

def test_fails_open_when_the_state_file_holds_a_non_object(self):
payload = {"session_id": "session-list"}
with open(detect.state_path(payload), "w", encoding="utf-8") as handle:
handle.write("[1, 2, 3]")
self.assertEqual(detect.load_state(payload), {})

def test_unreadable_transcript_records_no_contract_so_a_lock_holds(self):
payload = {"session_id": "session-2", "transcript_path": "/nonexistent/session.jsonl"}
self.assertEqual(detect.recorded_contract(payload, 0), "")

def test_missing_transcript_counts_zero_lines_rather_than_guessing(self):
payload = {"session_id": "session-3", "transcript_path": "/nonexistent/session.jsonl"}
self.assertEqual(detect._line_count(detect._transcript_path(payload)), 0)


class ScopeLockCase(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
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