From 8db2ae7e1722065692130dcd6cd916b77e165cc0 Mon Sep 17 00:00:00 2001 From: Piotr Czapla Date: Fri, 14 Aug 2026 10:07:09 +0200 Subject: [PATCH 1/3] harness: relaxed desktop mode with worked-round dojo substitute The desktop app has no launch flags, so no sysp replacement and no dojo-preloaded start. Hooks now detect it (CLAUDE_CODE_ENTRYPOINT) and substitute rather than enforce: SessionStart prints core.md, native-edit blocking and the bootstrap gate stay off, and claude-dojo-sample denies the first kernel call once with the worked round plus a completion id so dojo_start(id) skips the live round - study replaces play, as in the codex sample. Co-Authored-By: Claude Fable 5 --- SETUP.md | 6 +++-- aai_coding/harness.py | 54 +++++++++++++++++++++++++++++++++++++++---- tests/test_harness.py | 40 ++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/SETUP.md b/SETUP.md index 5dfce37..2c6c7b2 100644 --- a/SETUP.md +++ b/SETUP.md @@ -22,7 +22,9 @@ Settle first: existing non-symlink files at those paths. ## 3. Hooks -Outcome, Claude Code, in `~/.claude/settings.json` under `hooks`: PreToolUse matcher `Write|Edit|NotebookEdit` runs `aai-hook claude-block-native-edit`; PreToolUse matcher `Bash` runs `aai-hook claude-bash-guard`; UserPromptSubmit runs `aai-hook claude-prompt-submit`; SessionStart runs `aai-hook claude-session-start`; UserPromptSubmit, MessageDisplay, and PostToolBatch each also run `aai-hook claude-air` (the come-up-for-air nudge: after 8 tool-call rounds with no text response of 100+ chars, it injects a reminder to surface and reassess, repeating every 5 further rounds). The air nudge is Claude-only: codex has no message-level hook event, so it cannot observe the "text happened" reset condition - the codex-shaped substitute is a sentence in AGENTS.md; revisit if codex grows one. PostToolBatch and Stop also each run `aai-hook claude-drop-sentinel`, a Python port of podlayer/message-drop-sentinel (MIT): it detects the thinking-sandwich message-drop platform bug from the transcript scar (two adjacent thinking blocks) and tells the agent its text was probably eaten: restate it in the turn-final message, or say it now and end the turn if the user needs it immediately. Retire the sentinel entries when the upstream bug is fixed (re-test recipe and issue links in that repo's README). Bare `aai-hook` resolves because the user's shell profile puts the workspace venv on PATH; if it does not, use the absolute venv path. +Outcome, Claude Code, in `~/.claude/settings.json` under `hooks`: PreToolUse matcher `Write|Edit|NotebookEdit` runs `aai-hook claude-block-native-edit`; PreToolUse matcher `Bash` runs `aai-hook claude-bash-guard`; UserPromptSubmit runs `aai-hook claude-prompt-submit`; SessionStart runs `aai-hook claude-session-start`; UserPromptSubmit, MessageDisplay, and PostToolBatch each also run `aai-hook claude-air` (the come-up-for-air nudge: after 8 tool-call rounds with no text response of 100+ chars, it injects a reminder to surface and reassess, repeating every 5 further rounds). The air nudge is Claude-only: codex has no message-level hook event, so it cannot observe the "text happened" reset condition - the codex-shaped substitute is a sentence in AGENTS.md; revisit if codex grows one. PostToolBatch and Stop also each run `aai-hook claude-drop-sentinel`, a Python port of podlayer/message-drop-sentinel (MIT): it detects the thinking-sandwich message-drop platform bug from the transcript scar (two adjacent thinking blocks) and tells the agent its text was probably eaten: restate it in the turn-final message, or say it now and end the turn if the user needs it immediately. Retire the sentinel entries when the upstream bug is fixed (re-test recipe and issue links in that repo's README). PreToolUse matcher `mcp__clikernel__execute` runs `aai-hook claude-dojo-sample`, the desktop dojo substitute described below. Bare `aai-hook` resolves because the user's shell profile puts the workspace venv on PATH; if it does not, use the absolute venv path. + +Desktop app: the desktop currently has no launch flags, so no sysp replacement and no dojo-preloaded start (`claude -r $(claudedojo)`). The hooks detect it (`CLAUDE_CODE_ENTRYPOINT` = `claude-desktop`) and substitute rather than enforce: SessionStart prints `prompts/core.md`; the bootstrap gate and native-edit blocking stay off; the first kernel call in a Python project is denied once with the worked round and a completion id, so `dojo_start(id)` skips the live round - study replaces play, as in the codex sample. Revisit if the desktop gains launch options. Outcome, codex, in `~/.codex/hooks.json`: PostCompact, SessionStart with matcher `compact`, and PreToolUse with matcher `mcp__clikernel__execute` each run `/bin/aai-hook codex-orientation`; UserPromptSubmit runs `/bin/aai-hook codex-prompt-submit`. codex asks the user to trust hooks on the first start after any `hooks.json` change; tell them to expect that prompt. @@ -36,7 +38,7 @@ Outcome, in `settings.json`: `permissions.deny` includes `Read`, `Edit`, `Write` Recommended, ask the user: `disableBundledSkills` set to `true` in `settings.json`, turning off the built-in skills (`init`, `review`, `code-review`, `security-review`, `simplify`, `verify`, `run`, `dataviz`, `artifact-design`, `fewer-permission-prompts`, `update-config`, `keybindings-help`), which assume the native file tools this deny list removes. -Settle first: any existing rule that conflicts. In particular a broad `Bash` allow rule defeats both the bash guard and safecmd; surface that one explicitly. +Settle first: any existing rule that conflicts. In particular a broad `Bash` allow rule defeats both the bash guard and safecmd; surface that one explicitly. Also whether the user works in the desktop app: settings cannot branch by frontend, so this deny list would reach desktop sessions the step 3 hooks deliberately leave native. Such users put these permissions in a `--settings` file on the CLI alias instead. Check: the file still parses as JSON after editing. diff --git a/aai_coding/harness.py b/aai_coding/harness.py index 87ffbf1..9c656f7 100644 --- a/aai_coding/harness.py +++ b/aai_coding/harness.py @@ -79,11 +79,32 @@ def _forget(session_id): except Exception: pass +def _desktop(): + "True in a Claude desktop app session, which runs the relaxed harness: the desktop can neither replace the system prompt nor start dojo-preloaded" + return os.environ.get('CLAUDE_CODE_ENTRYPOINT') == 'claude-desktop' + + +def _is_nbdev(d): + try: return any(l.startswith('[tool.nbdev]') for l in (d/'pyproject.toml').open()) + except OSError: return False + + +CORE_MD = Path(__file__).parent.parent/'prompts'/'core.md' + + +def claude_desktop_start(o, d): + "SessionStart, desktop app: core behavioral rules and the nbdev caution; kernel-only enforcement stays off" + if o.get('source') == 'compact': _forget(o.get('session_id', '')) + print(CORE_MD.read_text()) + if _is_nbdev(d): print(NBDEV_MSG) + + def claude_session_start(o): - "SessionStart: orientation notice by source, then Python-project bootstrap and nbdev addenda" + "SessionStart: orientation notice by source, then Python-project bootstrap and nbdev addenda (relaxed in the desktop app)" d = Path(os.environ.get('CLAUDE_PROJECT_DIR') or os.getcwd()) src = o.get('source', '') if src in ('resume', 'compact'): print(f'[{src} at {datetime.now():%H:%M:%S}]') + if _desktop(): return claude_desktop_start(o, d) if src == 'compact': _forget(o.get('session_id', '')) print(COMPACT_MSG) @@ -92,9 +113,7 @@ def claude_session_start(o): print(SYNTH_MSG) elif src == 'resume' and (d/'pyproject.toml').is_file(): print(RESUME_MSG) if (d/'pyproject.toml').is_file(): print(BOOTSTRAP_MSG) - try: nb = any(l.startswith('[tool.nbdev]') for l in (d/'pyproject.toml').open()) - except OSError: nb = False - if nb: print(NBDEV_MSG) + if _is_nbdev(d): print(NBDEV_MSG) def _prompt_submit(o, q_notice): @@ -121,7 +140,8 @@ def claude_bash_guard(o): def claude_block_native_edit(o): - "PreToolUse(Write|Edit|NotebookEdit): route edits to the kernel tooling" + "PreToolUse(Write|Edit|NotebookEdit): route edits to the kernel tooling (desktop sessions keep native tools)" + if _desktop(): return print(BLOCK_EDIT_MSG, file=sys.stderr) sys.exit(2) @@ -280,6 +300,30 @@ def claude_slop(o): if notes: print(json.dumps(dict(hookSpecificOutput=dict( hookEventName='UserPromptSubmit', additionalContext='\n'.join(notes))))) except Exception as e: print(f'[slop] fail-open: {e!r}', file=sys.stderr) + + +DOJO_SAMPLE_MSG = ('This desktop session studies a worked dojo round instead of playing one. Read the round below as reference ' + 'for correct kernel tool usage; do not repeat or score it. Then run `dojo_start({cid!r})` in the kernel to record the skip, ' + 'and retry this call.\n\n{sample}') + + +def claude_dojo_sample(o): + "PreToolUse(mcp__clikernel__execute), desktop only: gate the first kernel call on studying the worked round" + try: + if not _desktop() or o.get('agent_id'): return + if not (Path(os.environ.get('CLAUDE_PROJECT_DIR') or os.getcwd())/'pyproject.toml').is_file(): return + f = _state_file('dojo-sample', o.get('session_id', '')) + if f.exists(): return + from llmdojo.claudedojo import _load_reg + _,meta = _load_reg(None) # side effect: registers the template's completion id, so dojo_start honors the skip + import llmdojo + sample = (Path(llmdojo.__file__).parent/'dojo_data/codexdojo_sample.md').read_text() + print(json.dumps(dict(hookSpecificOutput=dict(hookEventName='PreToolUse', permissionDecision='deny', + permissionDecisionReason=DOJO_SAMPLE_MSG.format(cid=meta['cid'], sample=sample))))) + f.write_text('{}') + except Exception as e: print(f'[dojo-sample] fail-open: {e!r}', file=sys.stderr) + + def codex_orientation(o): "codex PostCompact/SessionStart/PreToolUse: post-compaction doc-state reset and one-shot reorientation" state = Path(os.environ.get('LLMDOJO_STATE_DIR', Path.home()/'.local/state/llmdojo')) diff --git a/tests/test_harness.py b/tests/test_harness.py index 692c443..443648b 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -149,6 +149,46 @@ def out(): return capsys.readouterr().out assert out() == '' # unparseable transcript: fail-open, silent on stdout +def test_desktop_relaxed(tmp_path, monkeypatch, capsys): + "Desktop sessions keep native edits and swap the bootstrap gate for core.md; terminal sessions are unchanged" + from aai_coding.harness import claude_block_native_edit, claude_session_start + monkeypatch.setenv('CLAUDE_PROJECT_DIR', str(tmp_path)) + (tmp_path/'pyproject.toml').write_text('[tool.nbdev]\n') + monkeypatch.setenv('CLAUDE_CODE_ENTRYPOINT', 'claude-desktop') + claude_block_native_edit({}) # returns: native edits pass + claude_session_start(dict(source='startup', session_id='s1')) + out = capsys.readouterr().out + assert 'final text message' in out # core.md loaded + assert 'NEVER touch local files' not in out and 'nbdev project' in out + monkeypatch.setenv('CLAUDE_CODE_ENTRYPOINT', 'cli') + with pytest.raises(SystemExit): claude_block_native_edit({}) + claude_session_start(dict(source='startup', session_id='s1')) + out = capsys.readouterr().out + assert 'NEVER touch local files' in out and 'final text message' not in out + + +def test_dojo_sample(tmp_path, monkeypatch, capsys): + "First desktop kernel call in a Python project is denied with the worked round; replays, subagents, plain dirs, and terminal sessions pass" + from aai_coding.harness import claude_dojo_sample + monkeypatch.setenv('LLMDOJO_STATE_DIR', str(tmp_path)) + monkeypatch.setenv('CLAUDE_PROJECT_DIR', str(tmp_path)) + monkeypatch.setenv('CLAUDE_CODE_ENTRYPOINT', 'claude-desktop') + ev = dict(hook_event_name='PreToolUse', session_id='s1') + claude_dojo_sample(ev) + assert capsys.readouterr().out == '' # no pyproject.toml: not a kernel-regime project + (tmp_path/'pyproject.toml').write_text('') + claude_dojo_sample(ev) + r = json.loads(capsys.readouterr().out)['hookSpecificOutput'] + assert r['permissionDecision'] == 'deny' and 'dojo_start' in r['permissionDecisionReason'] + claude_dojo_sample(ev) + assert capsys.readouterr().out == '' # studied once: later calls pass + claude_dojo_sample(dict(hook_event_name='PreToolUse', session_id='s2', agent_id='sub1')) + assert capsys.readouterr().out == '' # subagents pass + monkeypatch.setenv('CLAUDE_CODE_ENTRYPOINT', 'cli') + claude_dojo_sample(dict(hook_event_name='PreToolUse', session_id='s3')) + assert capsys.readouterr().out == '' # terminal sessions play the real round + + @pytest.mark.skipif(not which('slopometer'), reason='slopometer not installed') def test_slop(tmp_path, monkeypatch, capsys): "Sloppy previous message -> context rows at the next prompt; repeats, subagents, short and clean prose stay silent" From 17ea2b3106703dabb69d8d83e86abbc2cb36edd0 Mon Sep 17 00:00:00 2001 From: Piotr Czapla Date: Fri, 14 Aug 2026 23:56:55 +0200 Subject: [PATCH 2/3] harness: exempt desktop sessions from the bash guard Co-Authored-By: Claude Fable 5 --- SETUP.md | 2 +- aai_coding/harness.py | 3 ++- tests/test_harness.py | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/SETUP.md b/SETUP.md index 2c6c7b2..e8c4986 100644 --- a/SETUP.md +++ b/SETUP.md @@ -24,7 +24,7 @@ Settle first: existing non-symlink files at those paths. Outcome, Claude Code, in `~/.claude/settings.json` under `hooks`: PreToolUse matcher `Write|Edit|NotebookEdit` runs `aai-hook claude-block-native-edit`; PreToolUse matcher `Bash` runs `aai-hook claude-bash-guard`; UserPromptSubmit runs `aai-hook claude-prompt-submit`; SessionStart runs `aai-hook claude-session-start`; UserPromptSubmit, MessageDisplay, and PostToolBatch each also run `aai-hook claude-air` (the come-up-for-air nudge: after 8 tool-call rounds with no text response of 100+ chars, it injects a reminder to surface and reassess, repeating every 5 further rounds). The air nudge is Claude-only: codex has no message-level hook event, so it cannot observe the "text happened" reset condition - the codex-shaped substitute is a sentence in AGENTS.md; revisit if codex grows one. PostToolBatch and Stop also each run `aai-hook claude-drop-sentinel`, a Python port of podlayer/message-drop-sentinel (MIT): it detects the thinking-sandwich message-drop platform bug from the transcript scar (two adjacent thinking blocks) and tells the agent its text was probably eaten: restate it in the turn-final message, or say it now and end the turn if the user needs it immediately. Retire the sentinel entries when the upstream bug is fixed (re-test recipe and issue links in that repo's README). PreToolUse matcher `mcp__clikernel__execute` runs `aai-hook claude-dojo-sample`, the desktop dojo substitute described below. Bare `aai-hook` resolves because the user's shell profile puts the workspace venv on PATH; if it does not, use the absolute venv path. -Desktop app: the desktop currently has no launch flags, so no sysp replacement and no dojo-preloaded start (`claude -r $(claudedojo)`). The hooks detect it (`CLAUDE_CODE_ENTRYPOINT` = `claude-desktop`) and substitute rather than enforce: SessionStart prints `prompts/core.md`; the bootstrap gate and native-edit blocking stay off; the first kernel call in a Python project is denied once with the worked round and a completion id, so `dojo_start(id)` skips the live round - study replaces play, as in the codex sample. Revisit if the desktop gains launch options. +Desktop app: the desktop currently has no launch flags, so no sysp replacement and no dojo-preloaded start (`claude -r $(claudedojo)`). The hooks detect it (`CLAUDE_CODE_ENTRYPOINT` = `claude-desktop`) and substitute rather than enforce: SessionStart prints `prompts/core.md`; the bootstrap gate, native-edit blocking, and the bash guard stay off; the first kernel call in a Python project is denied once with the worked round and a completion id, so `dojo_start(id)` skips the live round - study replaces play, as in the codex sample. Revisit if the desktop gains launch options. Outcome, codex, in `~/.codex/hooks.json`: PostCompact, SessionStart with matcher `compact`, and PreToolUse with matcher `mcp__clikernel__execute` each run `/bin/aai-hook codex-orientation`; UserPromptSubmit runs `/bin/aai-hook codex-prompt-submit`. codex asks the user to trust hooks on the first start after any `hooks.json` change; tell them to expect that prompt. diff --git a/aai_coding/harness.py b/aai_coding/harness.py index 9c656f7..24c8839 100644 --- a/aai_coding/harness.py +++ b/aai_coding/harness.py @@ -133,7 +133,8 @@ def codex_prompt_submit(o): def claude_bash_guard(o): - "PreToolUse(Bash): reject output-truncating pipes" + "PreToolUse(Bash): reject output-truncating pipes (desktop sessions are exempt)" + if _desktop(): return if m := bash_guard_msg(o.get('tool_input', {}).get('command') or ''): print(m, file=sys.stderr) sys.exit(2) diff --git a/tests/test_harness.py b/tests/test_harness.py index 443648b..ce8bb0e 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -151,17 +151,19 @@ def out(): return capsys.readouterr().out def test_desktop_relaxed(tmp_path, monkeypatch, capsys): "Desktop sessions keep native edits and swap the bootstrap gate for core.md; terminal sessions are unchanged" - from aai_coding.harness import claude_block_native_edit, claude_session_start + from aai_coding.harness import claude_bash_guard, claude_block_native_edit, claude_session_start monkeypatch.setenv('CLAUDE_PROJECT_DIR', str(tmp_path)) (tmp_path/'pyproject.toml').write_text('[tool.nbdev]\n') monkeypatch.setenv('CLAUDE_CODE_ENTRYPOINT', 'claude-desktop') claude_block_native_edit({}) # returns: native edits pass + claude_bash_guard(dict(tool_input=dict(command='pytest | head -5'))) # returns: truncating pipes pass claude_session_start(dict(source='startup', session_id='s1')) out = capsys.readouterr().out assert 'final text message' in out # core.md loaded assert 'NEVER touch local files' not in out and 'nbdev project' in out monkeypatch.setenv('CLAUDE_CODE_ENTRYPOINT', 'cli') with pytest.raises(SystemExit): claude_block_native_edit({}) + with pytest.raises(SystemExit): claude_bash_guard(dict(tool_input=dict(command='pytest | head -5'))) claude_session_start(dict(source='startup', session_id='s1')) out = capsys.readouterr().out assert 'NEVER touch local files' in out and 'final text message' not in out From 2ce2b986e46c2231903e4c1dcf5378ba61246bef Mon Sep 17 00:00:00 2001 From: Piotr Czapla Date: Sat, 15 Aug 2026 00:15:39 +0200 Subject: [PATCH 3/3] harness: hook CLI contract tests; fail open on degenerate payloads Recording each subcommand as Claude Code invokes it (subprocess, JSON on stdin, both frontends) caught two crashes the frontend can surface as an interrupted tool call: claude-air raised KeyError on a payload without hook_event_name, and any hook raised JSONDecodeError on empty stdin. The new tests hold every registered pair to the contract - exit 0, stdout empty or one JSON object, nonzero exits only from intended CLI blocks - plus the degenerate payloads. Co-Authored-By: Claude Fable 5 --- aai_coding/harness.py | 8 +++-- tests/test_hook_cli.py | 69 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 tests/test_hook_cli.py diff --git a/aai_coding/harness.py b/aai_coding/harness.py index 24c8839..51cb797 100644 --- a/aai_coding/harness.py +++ b/aai_coding/harness.py @@ -170,7 +170,7 @@ def claude_air(o): except (OSError, ValueError): st = {} # missing, torn by a concurrent writer, or otherwise unreadable: start fresh if not isinstance(st, dict): st = {} st = {k: st.get(k, d) for k, d in dict(rounds=0, nudged=0, mid='', midlen=0).items()} - ev = o['hook_event_name'] + ev = o.get('hook_event_name') if ev == 'UserPromptSubmit': st.update(rounds=0, nudged=0) elif ev == 'MessageDisplay': if o.get('message_id') != st['mid']: st.update(mid=o.get('message_id'), midlen=0) @@ -358,5 +358,7 @@ def codex_orientation(o): def main(): - "Dispatch `aai-hook ` to its handler with the stdin JSON payload" - globals()[sys.argv[1].replace('-', '_')](json.load(sys.stdin)) + "Dispatch `aai-hook ` to its handler with the stdin JSON payload; an unreadable payload is a fail-open no-op, since the harness surfaces a crashed hook as a tool error" + try: o = json.load(sys.stdin) + except ValueError: return + globals()[sys.argv[1].replace('-', '_')](o) diff --git a/tests/test_hook_cli.py b/tests/test_hook_cli.py new file mode 100644 index 0000000..d710d50 --- /dev/null +++ b/tests/test_hook_cli.py @@ -0,0 +1,69 @@ +"""Subprocess-level contract tests for the aai-hook CLI: invoke each registered subcommand the way +Claude Code does - JSON event payload on stdin, desktop or CLI env - and hold it to the hook +contract. A hook that crashes (nonzero exit, traceback on stderr) or prints non-JSON where JSON is +expected is an error the frontend can surface as an interrupted tool call, so the contract is: +exit 0, stdout empty or one JSON object (SessionStart prints plain context text), nonzero exits +only from the intended CLI enforcement blocks, and no crash on degenerate payloads.""" +import json, os, subprocess, sys +from pathlib import Path + +import pytest + +HOOK = Path(sys.executable).parent/'aai-hook' +SUBS = ('claude-session-start', 'claude-bash-guard', 'claude-block-native-edit', 'claude-prompt-submit', + 'claude-air', 'claude-drop-sentinel', 'claude-slop', 'claude-dojo-sample') +EVENTS = [ # every (subcommand, payload) pair the SETUP.md registrations can produce + ('claude-session-start', dict(hook_event_name='SessionStart', source='startup')), + ('claude-bash-guard', dict(hook_event_name='PreToolUse', tool_name='Bash', tool_input=dict(command='ls'))), + ('claude-block-native-edit', dict(hook_event_name='PreToolUse', tool_name='Edit', tool_input=dict(file_path='x.py'))), + ('claude-prompt-submit', dict(hook_event_name='UserPromptSubmit', prompt='hello')), + ('claude-air', dict(hook_event_name='UserPromptSubmit', prompt='hello')), + ('claude-air', dict(hook_event_name='PostToolBatch', tool_calls=[])), + ('claude-air', dict(hook_event_name='MessageDisplay', message_id='m1', delta='x', final=True)), + ('claude-drop-sentinel', dict(hook_event_name='PostToolBatch')), + ('claude-drop-sentinel', dict(hook_event_name='Stop')), + ('claude-slop', dict(hook_event_name='MessageDisplay', message_id='m1', delta='x', final=True)), + ('claude-slop', dict(hook_event_name='UserPromptSubmit', prompt='next')), + ('claude-dojo-sample', dict(hook_event_name='PreToolUse', tool_name='mcp__clikernel__execute')), +] + + +def run_hook(sub, payload, tmp, desktop=False): + env = os.environ | dict(LLMDOJO_STATE_DIR=str(tmp), CLAUDE_PROJECT_DIR=str(tmp)) + env.pop('CLAUDE_CODE_ENTRYPOINT', None) + if desktop: env['CLAUDE_CODE_ENTRYPOINT'] = 'claude-desktop' + inp = payload if isinstance(payload, str) else json.dumps(payload) + return subprocess.run([str(HOOK), sub], input=inp, text=True, capture_output=True, env=env, timeout=60) + + +def out_ok(r, sub): + if not r.stdout.strip(): return True + if sub == 'claude-session-start': return True # SessionStart stdout is plain context text + return isinstance(json.loads(r.stdout), dict) + + +@pytest.mark.parametrize('desktop', (False, True)) +def test_registered_events(tmp_path, desktop): + "Every registered pair honors the contract; the only nonzero exit is the CLI native-edit block" + for sub, ev in EVENTS: + r = run_hook(sub, dict(ev, session_id='s1'), tmp_path, desktop) + expected = 2 if sub == 'claude-block-native-edit' and not desktop else 0 + assert r.returncode == expected, (sub, ev, r.returncode, r.stderr) + assert out_ok(r, sub), (sub, ev, r.stdout) + + +def test_intended_blocks(tmp_path): + "CLI enforcement blocks exit 2 with the redirect on stderr and nothing on stdout" + r = run_hook('claude-block-native-edit', dict(hook_event_name='PreToolUse', session_id='s1', tool_input=dict(file_path='x')), tmp_path) + assert r.returncode == 2 and 'clikernel' in r.stderr and r.stdout == '' + r = run_hook('claude-bash-guard', dict(hook_event_name='PreToolUse', session_id='s1', tool_input=dict(command='x | head -3')), tmp_path) + assert r.returncode == 2 and 'truncat' in r.stderr and r.stdout == '' + + +def test_degenerate_payloads(tmp_path): + "An empty payload or empty stdin never crashes a hook: the frontend treats a crash as an error" + for sub in SUBS: + for payload in ({}, ''): + r = run_hook(sub, payload, tmp_path, desktop=True) + assert r.returncode == 0, (sub, payload, r.stderr) + assert out_ok(r, sub), (sub, payload, r.stdout)