Files
hermes-agent/evals/desktop_bug_campaign/hooks_live.py
T

140 lines
7.9 KiB
Python

"""Real serve + WebSocket + local HTTP inference fixture hook probe (no vendor calls)."""
import argparse
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import yaml
from websockets.sync.client import connect
p = argparse.ArgumentParser()
p.add_argument('--output', required=True)
p.add_argument('--port', type=int, default=18000)
p.add_argument('--tool', choices=['write_file', 'terminal'], default='write_file')
p.add_argument('--repo', type=Path, default=Path(__file__).resolve().parents[2])
a = p.parse_args()
out = Path(a.output).absolute()
if out.exists() and any(out.iterdir()):
raise SystemExit('Use a fresh output directory; prior evidence must not be reused.')
out.mkdir(parents=True, exist_ok=True)
home = out / 'home'
home.mkdir(exist_ok=True)
requests = []
class Provider(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def do_POST(self):
req = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
requests.append(req)
messages = req.get('messages', [])
last_user = next((m['content'] for m in reversed(messages) if m['role'] == 'user'), '')
label = next((x for x in ['alpha', 'beta', 'default', 'unapproved'] if x in str(last_user)), 'default')
tool_seen = any(m['role'] == 'tool' for m in messages)
target = str(out / (label + '-protected.txt'))
tool_args = ({'path': target, 'content': 'UNGUARDED'} if a.tool == 'write_file' else {
'command': f'printf %s UNGUARDED > {shlex.quote(target)}'})
delta = {'role': 'assistant', 'content': 'fixture complete'} if tool_seen or not req.get('tools') else {
'role': 'assistant', 'tool_calls': [{'index': 0, 'id': 'call_probe', 'type': 'function', 'function': {
'name': a.tool, 'arguments': json.dumps(tool_args)}}]}
finish = 'stop' if 'content' in delta else 'tool_calls'
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream' if req.get('stream') else 'application/json')
self.end_headers()
if req.get('stream'):
for d, f in [(delta, None), ({}, finish)]:
self.wfile.write(('data: ' + json.dumps({'id': 'fixture', 'object': 'chat.completion.chunk', 'choices': [{'index': 0, 'delta': d, 'finish_reason': f}]}) + '\n\n').encode())
self.wfile.write(b'data: [DONE]\n\n')
else:
delta.pop('index', None)
for tc in delta.get('tool_calls', []):
tc.pop('index', None)
self.wfile.write(json.dumps({'id': 'fixture', 'object': 'chat.completion', 'choices': [{'index': 0, 'message': delta, 'finish_reason': finish}], 'usage': {'prompt_tokens': 100, 'completion_tokens': 10}}).encode())
provider = ThreadingHTTPServer(('127.0.0.1', a.port + 1), Provider)
threading.Thread(target=provider.serve_forever, daemon=True).start()
for label in ['default', 'alpha', 'beta', 'unapproved']:
h = home if label == 'default' else home / 'profiles' / label
h.mkdir(parents=True, exist_ok=True)
hook = h / 'guard.py'
hook.write_text('import json,sys\nfrom pathlib import Path\npayload=json.load(sys.stdin)\nwith Path(' + repr(str(out / (label + '-hooks.jsonl'))) + ').open("a") as f: f.write(json.dumps(payload)+"\\n")\nprint(json.dumps({"decision":"block","reason":' + repr('guard-' + label) + '}))\n')
cfg = {'model': {'default': 'fixture-model', 'provider': 'custom', 'base_url': f'http://127.0.0.1:{a.port+1}/v1', 'api_key': 'fixture-key'},
'hooks_auto_accept': label != 'unapproved', 'hooks': {'pre_tool_call': [{'command': shlex.join([sys.executable, str(hook)]), 'matcher': a.tool, 'fail_closed': True}]},
'toolsets': ['file', 'terminal'], 'agent': {'max_turns': 3}, 'memory': {'memory_enabled': False, 'user_profile_enabled': False},
'curator': {'enabled': False}, 'compression': {'enabled': False}, 'terminal': {'cwd': str(out)}}
(h / 'config.yaml').write_text(yaml.safe_dump(cfg))
(h / '.env').write_text('OPENAI_API_KEY=fixture-key\nOPENAI_BASE_URL=http://127.0.0.1:' + str(a.port+1) + '/v1\n')
env = {k: v for k, v in os.environ.items() if not (k.startswith('HERMES_') or k.endswith(('_API_KEY', '_TOKEN')))}
env.update(HOME=str(out / 'os-home'), HERMES_HOME=str(home), HERMES_DASHBOARD_SESSION_TOKEN='hooks-fixture-token', HERMES_IGNORE_RULES='1', PYTHONPATH=str(a.repo))
cmd = [sys.executable, '-m', 'hermes_cli.main', 'serve', '--host', '127.0.0.1', '--port', str(a.port), '--skip-build']
log = (out / 'serve.log').open('w')
proc = subprocess.Popen(cmd, cwd=a.repo, env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT)
events = []
results = []
try:
deadline = time.monotonic() + 90
while True:
try:
ws = connect(f'ws://127.0.0.1:{a.port}/api/ws?token=hooks-fixture-token', open_timeout=2)
break
except Exception:
if time.monotonic() > deadline or proc.poll() is not None:
raise RuntimeError('serve failed to become ready')
time.sleep(.3)
serial = 0
def rpc(method, params):
global serial
serial += 1
rid = str(serial)
ws.send(json.dumps({'jsonrpc': '2.0', 'id': rid, 'method': method, 'params': params}))
while True:
event = json.loads(ws.recv(timeout=90))
events.append(event)
if str(event.get('id')) == rid:
if 'error' in event:
raise RuntimeError(event)
return event['result']
for label in ['default', 'alpha', 'beta', 'alpha', 'unapproved']:
hook_log = out / (label + '-hooks.jsonl')
calls_before = len(hook_log.read_text().splitlines()) if hook_log.exists() else 0
session = rpc('session.create', {'source': 'gui', 'profile': '' if label == 'default' else label, 'cwd': str(out)})
sid = session['session_id']
rpc('prompt.submit', {'session_id': sid, 'text': f'{label} probe: write the protected file'})
deadline = time.monotonic() + 100
completed = False
while time.monotonic() < deadline:
event = json.loads(ws.recv(timeout=100))
events.append(event)
data = event.get('params', {})
if data.get('session_id') == sid and data.get('type') == 'message.complete':
completed = data.get('payload', {}).get('status') == 'complete'
break
assert completed, f'{label}: no successful completion for {sid}'
results.append({'profile': label, 'written': (out / (label + '-protected.txt')).exists(), 'hook_calls': len(hook_log.read_text().splitlines()) if hook_log.exists() else 0, 'hook_calls_before': calls_before})
print(results[-1], flush=True)
ws.close()
assert all(not row['written'] and row['hook_calls'] - row['hook_calls_before'] == 1 for row in results if row['profile'] != 'unapproved'), 'consented profile hook did not protect the tool exactly once'
assert results[-1]['written'] and results[-1]['hook_calls'] == 0, 'unapproved hook executed'
finally:
proc.terminate()
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
provider.shutdown()
(out / 'events.json').write_text(json.dumps(events, indent=2))
(out / 'requests.json').write_text(json.dumps(requests, indent=2))
receipt = {'sha': subprocess.check_output(['git', '-C', str(a.repo), 'rev-parse', 'HEAD'], stdin=subprocess.DEVNULL, text=True).strip(), 'source_blob': subprocess.check_output(['git', '-C', str(a.repo), 'hash-object', 'tui_gateway/server.py'], stdin=subprocess.DEVNULL, text=True).strip(), 'tool': a.tool, 'process_exit': proc.returncode, 'command': cmd, 'results': results, 'provider_requests': len(requests)}
(out / 'receipt.json').write_text(json.dumps(receipt, indent=2))
print(json.dumps(receipt, indent=2))