Skip to content
Open
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 corpus/CLAUDE.learned.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ Engine-only install drops this file; reflect Accepted global rules land here.
- Before asking me whether to run a script, read its whole body, not its header comment, usage text, or `--dry-run` output, and name in the question every process it kills, file it deletes, and service it restarts. A header states what the author meant; the body is what runs, so a consent question built from the header asks me to approve something other than what will happen. No known prior art.
- When finishing a merge or rebase, reconcile three things before calling it resolved: what each side declared (its commit messages and PR description: the intent, and the tests it names), what each side's code actually does, and what the resolved result does. All three must agree: every declared intent is still visible in `git diff <base> -- <file>`, and every test either side named still passes on the result. A merge with no textual conflict can still break one side's declared behavior, so a clean merge is checked the same way. Never take one side's whole file (`git checkout --theirs` / `--ours <file>`, or copying the file from one branch) unless the other side's changes to that file are provably empty; taking a whole side silently reverts what the other branch added, including hunks that never conflicted (git-checkout(1), `--ours`/`--theirs`: https://git-scm.com/docs/git-checkout#Documentation/git-checkout.txt---ours). When a declaration and its code disagree, or the two sides' intents contradict, stop and flag it to me instead of choosing: run `why` on the conflicting lines (`git log -L` or `git log -S` plus each commit's PR body) and apply `principle-prove-it`, so the flag names the two commits or PRs, quotes the declared intent next to the code, and pastes the failing test output. Brun, Holmes, Ernst and Notkin, "Proactive Detection of Collaboration Conflicts" (ESEC/FSE 2011, https://doi.org/10.1145/2025113.2025139), across Git, Perl5 and Voldemort, found "that 33% of merges that were reported to contain no textual conflicts by the VCS in fact contained higher-order conflicts" (build or test failures), so a clean textual merge is not evidence the two intentions still hold.
- An item I approved as "fix it properly later" stays in every status update and every final summary, marked open, until it is done or I drop it. A recap that lists only what finished makes the deferred half disappear, and the next session never learns it was owed. No known prior art.
- Before spending more than a couple of tool calls on an infrastructure, security, or "does X exist" question, search saved memory for the topic and for its reverse framing: the fix as well as the symptom, "X is off" as well as "X is missing". An answer already written down costs one read; rediscovering it costs the whole investigation. No known prior art.
2 changes: 1 addition & 1 deletion engine/hooks/_sdk/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def run_hook(
event = json.load(sys.stdin)
except json.JSONDecodeError as exc:
_write_findings_file([])
print(f"catstack-hook-error {hook}: JSONDecodeError: {exc}", file=sys.stderr)
print(f"catstack-hook-error {hook}: JSONDecodeError: hook payload is not JSON: {exc}", file=sys.stderr)
stdout_text, _stderr_text, _exit_code = render(
harness,
hook_event_name or "",
Expand Down
19 changes: 14 additions & 5 deletions engine/hooks/cat-mode-default/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,22 @@ It stays silent when the flag is off or when the prompt already mentions
cat-mode anywhere (a parent that told the subagent to read it gets no
second copy). Same flag resolution as the prompt hook.

The shared registry keeps this hook in `warn` mode. Set
`CATSTACK_HOOK_MODE_CAT_MODE_DEFAULT=off|warn|stop` for a machine-local
override; `stop` turns the `PreToolUse` (Agent) companion into a real block
(exit 2) instead of rewriting the subagent's prompt. Detection or metrics
failures allow the harness action.

## Files

- `detect.py`: flag resolution, typed `/cat-mode` detection, context text.
- `claude_prompt_submit.py`: the Claude entrypoint; fail-open, never denies.
- `claude.prompt.hook.json`: settings fragment `install_claude_hook.py` merges.
- `claude_pretooluse_agent.py` + `claude.agent.hook.json`: the `PreToolUse`
(`Agent`) companion that carries the default into subagent prompts.
- `detect.py`: flag resolution, typed `/cat-mode` detection, context text,
and `detect(event)`, the SDK entrypoint returning `Finding` objects.
- `claude_prompt_submit.py` / `claude_pretooluse_agent.py`: thin calls into
`engine/hooks/_sdk/runtime.py`. The agent finding carries
`output={"updatedInput": ...}`, which the shared renderer emits as
`updatedInput` rather than its generic `additionalContext`.
- `claude.prompt.hook.json` / `claude.agent.hook.json`: settings fragments
`install_claude_hook.py` merges.
- `tests/fixtures/*.json`: one scenario each (fires / silent) with the
environment, optional `.env` content, and payload.
- `tests/fixtures/agent_*.json`: the same for the Agent-tool companion.
Expand Down
14 changes: 14 additions & 0 deletions engine/hooks/cat-mode-default/tests/test_agent_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import json
import os
import sys
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from unittest.mock import patch
Expand Down Expand Up @@ -150,6 +151,19 @@ def test_unrelated_prompt_does_not_count(self) -> None:


class FailOpenCase(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.metrics_env = patch.dict(
os.environ,
{"CATSTACK_HOOK_METRICS_DIR": self.tmp.name},
clear=False,
)
self.metrics_env.start()

def tearDown(self) -> None:
self.metrics_env.stop()
self.tmp.cleanup()

def test_malformed_stdin_prints_nothing(self) -> None:
out = io.StringIO()
with patch.object(sys, "stdin", io.StringIO("not json")):
Expand Down
13 changes: 13 additions & 0 deletions engine/hooks/cat-mode-default/tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,19 @@ def test_flags_missing_install_instead_of_dead_path(self) -> None:


class FailOpenCase(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.metrics_env = patch.dict(
os.environ,
{"CATSTACK_HOOK_METRICS_DIR": self.tmp.name},
clear=False,
)
self.metrics_env.start()

def tearDown(self) -> None:
self.metrics_env.stop()
self.tmp.cleanup()

def test_malformed_stdin_prints_nothing(self) -> None:
out = io.StringIO()
with patch.object(sys, "stdin", io.StringIO("not json")):
Expand Down
13 changes: 9 additions & 4 deletions engine/hooks/categorical-scope-guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,12 @@ Do one of two things:

## Three outcomes, and the fail direction

- **HIT** — exit 2 with the message above.
- **HIT** — finding `categorical-scope-guard.partial-status-filter`. The
registry default is `stop`, so the shared runtime exits 2 with the
message above unless a local mode override lowers it to a warning.
- **CLEAN** — exit 0.
- **UNCHECKED** — exit 2. **This hook fails closed.** It blocks, and says
- **UNCHECKED** — finding `categorical-scope-guard.unchecked`. **This hook
fails closed.** With the default `stop` mode it blocks, and says
`UNCHECKED`, when:
- the transcript path is absent, the file is missing, a line is malformed
JSON (a torn final line is tolerated), the window runs past the 64 MB
Expand Down Expand Up @@ -132,8 +135,10 @@ reason and let the user answer.

## Files

- `detect.py` — command parser, human-turn reader, `decide()`.
- `claude_pretooluse.py` — the entrypoint.
- `detect.py` — command parser, human-turn reader, `decide()`, and
`detect(event)`.
- `claude_pretooluse.py`, `cursor_pretooluse.py`, `codex_pretooluse.py` —
thin runtime entrypoints.
- `claude.hook.json`, `install_claude_hook.py` — the settings merge that
`install.sh` runs.
- `tests/test_hooks.py`, `tests/fixtures/` — the fixtures are real commands
Expand Down
48 changes: 6 additions & 42 deletions engine/hooks/categorical-scope-guard/claude_pretooluse.py
Original file line number Diff line number Diff line change
@@ -1,55 +1,19 @@
#!/usr/bin/env python3
"""Claude PreToolUse on Bash: block a status-narrowed mutation of a target the
live human turn quantified with all / every / each. Exits 2 with the reason on
stderr for HIT and for UNCHECKED; exits 0 for CLEAN.
"""
"""Claude Code PreToolUse entrypoint for categorical-scope-guard."""
from __future__ import annotations

import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk"))

from detect import CLEAN, decide_payload

SHELL_LIKE_TOOL_NAMES = (
"Bash", "bash", "shell", "Shell", "exec", "exec_command",
"run_terminal_cmd", "local_shell", "run_command", "shell_call",
)


def _tool_name(payload: dict) -> str:
return str(
payload.get("tool_name")
or payload.get("toolName")
or payload.get("tool")
or payload.get("name")
or ""
)
from detect import detect # noqa: E402
from runtime import run_hook # noqa: E402


def main() -> None:
raw = sys.stdin.read()
try:
payload = json.loads(raw)
except (json.JSONDecodeError, OSError) as exc:
sys.stderr.write(f"categorical-scope-guard: hook payload is not JSON, nothing to classify: {exc}\n")
return
if not isinstance(payload, dict) or _tool_name(payload) not in SHELL_LIKE_TOOL_NAMES:
return
try:
verdict = decide_payload(payload)
except Exception as exc:
sys.stderr.write(
f"categorical-scope-guard: UNCHECKED -- the detector failed ({exc!r}) while classifying a "
"status-filtered mutation. Blocked rather than passed; drop the status filter or rephrase the command.\n"
)
sys.exit(2)
if verdict.outcome == CLEAN:
return
sys.stderr.write(verdict.message + "\n")
sys.exit(2)
run_hook("categorical-scope-guard", "claude", detect, "PreToolUse")


if __name__ == "__main__":
Expand Down
20 changes: 20 additions & 0 deletions engine/hooks/categorical-scope-guard/codex_pretooluse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""Codex PreToolUse entrypoint for categorical-scope-guard."""
from __future__ import annotations

import os
import sys

sys.path.insert(0, os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk"))

from detect import detect # noqa: E402
from runtime import run_hook # noqa: E402


def main() -> None:
run_hook("categorical-scope-guard", "codex", detect, "PreToolUse")


if __name__ == "__main__":
main()
20 changes: 20 additions & 0 deletions engine/hooks/categorical-scope-guard/cursor_pretooluse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""Cursor PreToolUse entrypoint for categorical-scope-guard."""
from __future__ import annotations

import os
import sys

sys.path.insert(0, os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk"))

from detect import detect # noqa: E402
from runtime import run_hook # noqa: E402


def main() -> None:
run_hook("categorical-scope-guard", "cursor", detect, "PreToolUse")


if __name__ == "__main__":
main()
61 changes: 61 additions & 0 deletions engine/hooks/categorical-scope-guard/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,24 @@
from __future__ import annotations
import sys

import hashlib
import json
import os
import re
from dataclasses import dataclass, field
from pathlib import Path

SDK_DIR = Path(__file__).resolve().parents[1] / "_sdk"
if str(SDK_DIR) not in sys.path:
sys.path.insert(0, str(SDK_DIR))

from finding import Finding # noqa: E402

HIT = "hit"
CLEAN = "clean"
UNCHECKED = "unchecked"
RULE_PARTIAL_STATUS_FILTER = "categorical-scope-guard.partial-status-filter"
RULE_UNCHECKED = "categorical-scope-guard.unchecked"

LIVE_TURNS = 4
MAX_SCAN_BYTES = 64 * 1024 * 1024
Expand Down Expand Up @@ -872,3 +882,54 @@ def decide_payload(payload: dict) -> Verdict:
return Verdict(CLEAN)
path = payload.get("transcript_path") or payload.get("transcriptPath") or ""
return decide(command, lambda: read_live_window(path if isinstance(path, str) else ""))


SHELL_LIKE_TOOL_NAMES = (
"Bash", "bash", "shell", "Shell", "exec", "exec_command",
"run_terminal_cmd", "local_shell", "run_command", "shell_call",
)


def _tool_name(payload: dict) -> str:
return str(
payload.get("tool_name")
or payload.get("toolName")
or payload.get("tool")
or payload.get("name")
or ""
)


def _command_subject(payload: dict) -> str:
tool_input = payload.get("tool_input") or payload.get("toolInput") or {}
command = tool_input.get("command") if isinstance(tool_input, dict) else ""
if not isinstance(command, str):
command = ""
return "command:" + hashlib.sha256(command.encode("utf-8")).hexdigest()[:16]


def _finding(rule_id: str, payload: dict, message: str) -> Finding:
return Finding(
rule_id=rule_id,
subject=_command_subject(payload),
message=message,
evidence=message,
)


def detect(event: dict) -> list[Finding]:
if _tool_name(event) not in SHELL_LIKE_TOOL_NAMES:
return []
try:
verdict = decide_payload(event)
except Exception as exc:
print(f"catstack-hook-error categorical-scope-guard: {type(exc).__name__}: {exc}", file=sys.stderr)
message = (
f"categorical-scope-guard: UNCHECKED -- the detector failed ({exc!r}) while classifying a "
"status-filtered mutation. Blocked rather than passed; drop the status filter or rephrase the command."
)
return [_finding(RULE_UNCHECKED, event, message)]
if verdict.outcome == CLEAN:
return []
rule_id = RULE_PARTIAL_STATUS_FILTER if verdict.outcome == HIT else RULE_UNCHECKED
return [_finding(rule_id, event, verdict.message)]
Loading
Loading