diff --git a/SETUP.md b/SETUP.md index 5dfce37..e8c4986 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, 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. @@ -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..51cb797 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): @@ -114,14 +133,16 @@ 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) 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) @@ -149,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) @@ -280,6 +301,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')) @@ -313,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_harness.py b/tests/test_harness.py index 692c443..ce8bb0e 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -149,6 +149,48 @@ 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_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 + + +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" 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)