"""Sandbox-only installer tests; curl never accesses the network.""" import json import os from pathlib import Path import shlex import subprocess import tempfile import unittest ROOT = Path(__file__).resolve().parent INSTALLER = ROOT / 'install-question-tools.sh' HOOK_SCRIPT = ROOT / 'grilling-question-hook.py' POLICY = (ROOT / 'grilling-question-tools.md').read_bytes() HOOK = HOOK_SCRIPT.read_bytes() BEGIN = b'' END = b'' BASE = 'https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056' class InstallerTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix='question-tools-test-') self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) self.home = self.root / 'home' self.home.mkdir() self.bin = self.root / 'bin' self.bin.mkdir() self.fixture = self.root / 'policy.md' self.fixture.write_bytes(POLICY) self.hook_fixture = self.root / 'hook.py' self.hook_fixture.write_bytes(HOOK) curl = self.bin / 'curl' curl.write_text( '#!/usr/bin/env python3\n' 'import os, pathlib, sys\n' 'url = sys.argv[-1]\n' 'with open(os.environ["CURL_LOG"], "a") as log: log.write(url + "\\n")\n' 'if os.environ.get("CURL_FAIL") and url.endswith(os.environ["CURL_FAIL"]): sys.exit(22)\n' 'name = "HOOK_FIXTURE" if url.endswith(".py") else "POLICY_FIXTURE"\n' 'sys.stdout.buffer.write(pathlib.Path(os.environ[name]).read_bytes())\n' ) curl.chmod(0o700) self.env = {key: value for key, value in os.environ.items() if key not in ( 'HOME', 'CLAUDE_CONFIG_DIR', 'QUESTION_TOOLS_REVISION', 'QUESTION_TOOLS_CLAUDE_HOOK')} self.env.update(HOME=str(self.home), PATH=str(self.bin) + ':' + os.environ['PATH'], POLICY_FIXTURE=str(self.fixture), HOOK_FIXTURE=str(self.hook_fixture), CURL_LOG=str(self.root / 'curl.log')) self.use_claude_dir(self.home / '.claude') def use_claude_dir(self, claude): self.claude_md = claude / 'CLAUDE.md' self.settings = claude / 'settings.json' self.policy = claude / 'grilling-question-tools/grilling-question-tools.md' self.hook = claude / 'grilling-question-tools/grilling-question-hook.py' def run_install(self, success=True): result = subprocess.run(['bash', str(INSTALLER)], env=self.env, cwd=self.root, capture_output=True, text=True) self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr) return result def write_claude_md(self, data=b'Unrelated\x80 instructions\r\n'): self.claude_md.parent.mkdir(parents=True, exist_ok=True) self.claude_md.write_bytes(data) def snapshot(self): return {str(p.relative_to(self.home)): ( ('link', os.readlink(p)) if p.is_symlink() else ('file', p.read_bytes(), p.stat().st_mode, p.stat().st_mtime_ns) if p.is_file() else ('directory',)) for p in self.home.rglob('*')} def curl_urls(self): return (self.root / 'curl.log').read_text().splitlines() def managed_commands(self, settings=None): data = settings if settings is not None else json.loads(self.settings.read_text()) found = {} for event, groups in data.get('hooks', {}).items(): for group in groups: for hook in group.get('hooks', []): if 'grilling-question-hook.py' in hook.get('command', ''): found.setdefault(event, []).append((group.get('matcher'), hook['command'])) return found def run_hook(self, event): result = subprocess.run(['python3', str(HOOK_SCRIPT), str(self.fixture)], input=json.dumps(event), capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stderr) return json.loads(result.stdout) if result.stdout.strip() else None # Installer def test_install_and_unchanged_rerun(self): original = b'Unrelated\x80 instructions\r\n' self.write_claude_md(original) self.claude_md.chmod(0o640) self.run_install() self.assertEqual(self.policy.read_bytes(), POLICY) self.assertEqual(self.hook.read_bytes(), HOOK) content = self.claude_md.read_bytes() self.assertTrue(content.startswith(original)) self.assertEqual(content.count(BEGIN), 1) self.assertIn(str(self.policy).encode(), content) self.assertNotIn(POLICY, content) backups = list(self.claude_md.parent.glob('CLAUDE.md.question-tools-backup-*')) self.assertEqual(len(backups), 1) self.assertEqual(backups[0].read_bytes(), original) self.assertEqual(backups[0].stat().st_mode & 0o777, 0o600) self.assertEqual(self.claude_md.stat().st_mode & 0o777, 0o640) self.assertEqual(self.curl_urls(), [BASE + '/raw/HEAD/grilling-question-tools.md', BASE + '/raw/HEAD/grilling-question-hook.py']) # Only Claude Code files are touched. self.assertEqual(sorted(p.name for p in self.home.iterdir()), ['.claude']) before = self.snapshot() self.run_install() self.assertEqual(self.snapshot(), before) def test_hook_merged_into_existing_settings(self): existing = {'model': 'opus', 'hooks': {'PostToolUse': [ {'matcher': 'Bash', 'hooks': [{'type': 'command', 'command': 'echo mine'}]}]}} self.settings.parent.mkdir(parents=True) self.settings.write_text(json.dumps(existing)) self.run_install() data = json.loads(self.settings.read_text()) self.assertEqual(data['model'], 'opus') self.assertEqual(data['hooks']['PostToolUse'][0], existing['hooks']['PostToolUse'][0]) commands = self.managed_commands(data) self.assertEqual([m for m, _ in commands['PostToolUse']], ['Skill']) self.assertEqual([m for m, _ in commands['UserPromptSubmit']], [None]) self.assertEqual(shlex.split(commands['PostToolUse'][0][1]), ['python3', str(self.hook), str(self.policy)]) backups = list(self.settings.parent.glob('settings.json.question-tools-backup-*')) self.assertEqual(len(backups), 1) self.assertEqual(json.loads(backups[0].read_text()), existing) before = self.snapshot() self.run_install() self.assertEqual(self.snapshot(), before) def test_migrates_older_install(self): # An older version embedded the policy in CLAUDE.md and pointed the hook # at ~/.config/ai-agents. Both are replaced, not duplicated. prefix, suffix = b'Existing\xff\r\n\n', b'\r\nAfter\x80' self.write_claude_md(prefix + BEGIN + b'\nold embedded policy\n' + END + suffix) old_command = "python3 '/old/ai-agents/grilling-question-hook.py' '/old/ai-agents/grilling-question-tools.md'" self.settings.write_text(json.dumps({'hooks': {'PostToolUse': [ {'matcher': 'Skill', 'hooks': [{'type': 'command', 'command': old_command, 'timeout': 10}]}]}})) self.run_install() content = self.claude_md.read_bytes() self.assertTrue(content.startswith(prefix + BEGIN)) self.assertTrue(content.endswith(END + suffix)) self.assertEqual(content.count(BEGIN), 1) self.assertNotIn(b'old embedded policy', content) commands = self.managed_commands() self.assertEqual({event: len(entries) for event, entries in commands.items()}, {'PostToolUse': 1, 'UserPromptSubmit': 1}) self.assertNotIn('/old/', json.dumps(commands)) def test_symlinked_claude_md_and_directory(self): dotfiles = self.home / 'dotfiles' (dotfiles / 'claude').mkdir(parents=True) (dotfiles / 'claude/CLAUDE.md').write_bytes(b'Mine\n') (self.home / '.claude').symlink_to(dotfiles / 'claude', target_is_directory=True) self.run_install() self.assertTrue((self.home / '.claude').is_symlink()) self.assertEqual((dotfiles / 'claude/CLAUDE.md').read_bytes().count(BEGIN), 1) self.assertEqual((dotfiles / 'claude/grilling-question-tools/grilling-question-tools.md').read_bytes(), POLICY) def test_claude_config_dir_with_space_and_revision(self): self.env['CLAUDE_CONFIG_DIR'] = str(self.home / 'claude config') self.env['QUESTION_TOOLS_REVISION'] = 'a' * 40 self.use_claude_dir(self.home / 'claude config') self.run_install() self.assertEqual(self.policy.read_bytes(), POLICY) self.assertIn(str(self.policy).encode(), self.claude_md.read_bytes()) self.assertEqual(shlex.split(self.managed_commands()['PostToolUse'][0][1]), ['python3', str(self.hook), str(self.policy)]) self.assertEqual(self.curl_urls(), [BASE + '/raw/' + 'a' * 40 + '/grilling-question-tools.md', BASE + '/raw/' + 'a' * 40 + '/grilling-question-hook.py']) self.assertFalse((self.home / '.claude').exists()) self.env['QUESTION_TOOLS_REVISION'] = 'HEAD~1' self.run_install(False) def test_hook_opt_out_removes_only_managed_entries(self): self.run_install() self.assertTrue(self.managed_commands()) self.env['QUESTION_TOOLS_CLAUDE_HOOK'] = '0' self.run_install() self.assertEqual(self.managed_commands(), {}) self.assertEqual(json.loads(self.settings.read_text()), {'hooks': {}}) self.assertEqual(self.curl_urls()[2:], [BASE + '/raw/HEAD/grilling-question-tools.md']) self.assertEqual(self.claude_md.read_bytes().count(BEGIN), 1) self.env['QUESTION_TOOLS_CLAUDE_HOOK'] = 'yes' self.run_install(False) def test_malformed_blocks_preflight_no_writes(self): for malformed in (BEGIN, END + b'\n' + BEGIN, BEGIN + b'\nx\n' + END + END, b'prefix' + BEGIN + b'\nx\n' + END, BEGIN + b'x\n' + END): with self.subTest(malformed=malformed): self.write_claude_md(malformed) before = self.snapshot() self.run_install(False) self.assertEqual(self.snapshot(), before) def test_invalid_settings_abort_before_writes(self): for bad in (b'{not json', b'[]', b'{"hooks": []}', b'{"hooks": {"Stop": {}}}'): with self.subTest(bad=bad): self.write_claude_md() self.settings.write_bytes(bad) before = self.snapshot() self.run_install(False) self.assertEqual(self.snapshot(), before) def test_download_failure_and_invalid_files_no_writes(self): self.write_claude_md() before = self.snapshot() for failing in ('.md', '.py'): with self.subTest(failing=failing): self.env['CURL_FAIL'] = failing self.run_install(False) self.assertEqual(self.snapshot(), before) del self.env['CURL_FAIL'] self.hook_fixture.write_bytes(HOOK[:len(HOOK) // 2]) self.run_install(False) self.assertEqual(self.snapshot(), before) self.hook_fixture.write_bytes(HOOK) self.fixture.write_bytes(b'incomplete') self.run_install(False) self.assertEqual(self.snapshot(), before) def test_dangling_file_and_directory_symlinks(self): for path in (self.claude_md, self.claude_md.parent): with self.subTest(path=path): path.parent.mkdir(parents=True, exist_ok=True) path.symlink_to(self.home / 'missing') before = self.snapshot() self.run_install(False) self.assertEqual(self.snapshot(), before) path.unlink() if path == self.claude_md: path.parent.rmdir() def test_nonregular_destination(self): self.claude_md.parent.mkdir(parents=True) os.mkfifo(self.claude_md) self.run_install(False) self.assertFalse(self.policy.exists()) self.assertEqual(list(self.home.rglob('*.question-tools-backup-*')), []) @unittest.skipIf(os.geteuid() == 0, 'root bypasses filesystem permissions') def test_permission_failure_preflight(self): self.write_claude_md() self.claude_md.chmod(0o400) before = self.snapshot() self.run_install(False) self.assertEqual(self.snapshot(), before) def test_policy_cannot_alias_claude_md(self): self.write_claude_md() self.policy.parent.mkdir(parents=True) self.policy.symlink_to(self.claude_md) before = self.snapshot() self.run_install(False) self.assertEqual(self.snapshot(), before) # Hook def test_hook_triggers_only_for_grilling(self): post = {'hook_event_name': 'PostToolUse', 'tool_name': 'Skill'} for skill in ('grilling', 'grill-me', 'mattpocock-skills:grilling', 'grill-with-docs', 'loop-me'): with self.subTest(skill=skill): out = self.run_hook(dict(post, tool_input={'skill': skill})) self.assertEqual(out['hookSpecificOutput']['hookEventName'], 'PostToolUse') context = out['hookSpecificOutput']['additionalContext'] self.assertIn('AskUserQuestion', context) self.assertIn(POLICY.decode().strip(), context) prompt = {'hook_event_name': 'UserPromptSubmit'} for text in ('/loop-me', ' /grilling my plan', '/mattpocock-skills:grilling x'): with self.subTest(text=text): out = self.run_hook(dict(prompt, prompt=text)) self.assertEqual(out['hookSpecificOutput']['hookEventName'], 'UserPromptSubmit') for event in (dict(post, tool_input={'skill': 'tdd'}), dict(post, tool_name='Bash', tool_input={'command': 'grilling'}), dict(prompt, prompt='please grill me'), dict(prompt, prompt='/grilling-extra'), dict(prompt, prompt='/tdd /grilling'), # Covered by the Skill call these make; avoids double injection. dict(prompt, prompt='/grill-me'), dict(prompt, prompt='/grill-with-docs')): with self.subTest(event=event): self.assertIsNone(self.run_hook(event)) def test_hook_never_fails_the_session(self): for stdin, policy in (('not json', self.fixture), ('[]', self.fixture), (json.dumps({'hook_event_name': 'UserPromptSubmit', 'prompt': '/grilling'}), self.root / 'missing.md')): with self.subTest(stdin=stdin, policy=policy): result = subprocess.run(['python3', str(HOOK_SCRIPT), str(policy)], input=stdin, capture_output=True, text=True) self.assertEqual((result.returncode, result.stdout), (0, '')) if __name__ == '__main__': unittest.main()