"""Claude Code command hook. Classifies Bash input; never executes that input.""" import argparse import fcntl import hashlib import json import math import os import signal import sys import tempfile import time import urllib.request from pathlib import Path MAX_BYTES=65536 def output(decision,reason): return {'hookSpecificOutput':{'hookEventName':'PreToolUse','permissionDecision':decision.lower(),'permissionDecisionReason':'Laya: '+reason}} def atomic(path,value): fd,name=tempfile.mkstemp(dir=path.parent) try: with os.fdopen(fd,'w') as f:json.dump(value,f) os.replace(name,path) finally: if os.path.exists(name):os.unlink(name) def session_file(profile,event): sid=event.get('session_id') if not isinstance(sid,str) or not sid or len(sid)>256:raise ValueError('Missing session ID') return Path(profile['state_dir'])/(hashlib.sha256(sid.encode()).hexdigest()+'.json') def prompt_event(profile,event): path=session_file(profile,event) # Only the exact launch task is bound initially. Later prompts, including # autonomous messages, invalidate auto-approval rather than gain authority. with (path.parent/'context.lock').open('a') as lock: fcntl.flock(lock,fcntl.LOCK_EX) previous=json.loads(path.read_text()) if path.exists() else None valid=previous is None and event.get('prompt')==profile['intent'] and Path(event.get('cwd','')).resolve()==Path(profile['project']) atomic(path,{'valid':valid,'created':time.time(),'project':profile['project']}) return {} def query(profile,state,environ=None): environ=os.environ if environ is None else environ key=(environ.get(profile['api_key_env'],'') if profile.get('api_key_env') else Path(profile['api_key_file']).read_text()).strip() if not key:raise ValueError('Missing API credential') body={'model':profile['model'],'state':state,'questions':profile['questions']} request=urllib.request.Request(profile['endpoint'],data=json.dumps(body).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+key}) # Use the selected endpoint directly; do not leak prompts into ambient proxies. opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect()) with opener.open(request,timeout=profile['timeout_seconds']) as r: raw=r.read(MAX_BYTES+1) if len(raw)>MAX_BYTES:raise ValueError('Oversized response') result=json.loads(raw) if result.get('error'):raise ValueError('Classifier unavailable') answers=result.get('answers') if not isinstance(answers,dict) or set(answers)!={'permission'}:raise ValueError('Malformed answer') answer=answers['permission'];decision=answer.get('choice');p=answer.get('probabilities') if answer.get('type')!='choice' or decision not in ('ALLOW','ASK','DENY'):raise ValueError('Invalid decision') if not isinstance(p,dict) or set(p)!={'ALLOW','ASK','DENY'}:raise ValueError('Invalid probabilities') if any(isinstance(v,bool) or not isinstance(v,(int,float)) or not math.isfinite(v) or not 0<=v<=1 for v in p.values()) or abs(sum(p.values())-1)>.002:raise ValueError('Invalid probability distribution') # Keep the checkpoint's abstention. Independently enforce a minimum floor. if decision=='ALLOW' and p['ALLOW']MAX_BYTES:raise ValueError('Oversized hook input') event=json.loads(raw);profile=json.loads(a.profile.read_text()) if event.get('hook_event_name')=='UserPromptSubmit':result=prompt_event(profile,event) elif event.get('hook_event_name')=='PreToolUse':result=pretool(profile,event) else:raise ValueError('Unsupported hook event') except Exception: # Do not echo exceptions: URLs, commands, credentials or bodies may be in them. if event.get('hook_event_name')=='UserPromptSubmit': print(json.dumps({'decision':'block','reason':'Laya: could not bind task context. Restart the adapter session.'}));return result=output('ASK','classifier/context unavailable or malformed; manual review required') finally:signal.alarm(0) if profile.get('observe_only') and 'hookSpecificOutput' in result: result={'systemMessage':'Laya observation: '+result['hookSpecificOutput'].get('permissionDecisionReason','manual review')+'; normal Claude Code permission flow applies.'} print(json.dumps(result),flush=True) if __name__=='__main__':main()