
    epjN                       U d Z ddlmZ ddlZddlZddlZddlZddlZddlZddl	Z	ddl
Z
ddlZddlZddlZddlmZmZ ddlmZmZ ddlmZ ddlmZmZmZmZmZmZ ddlmZ dd	lmZ   ej!        e"          Z#d
Z$dZ%dZ&dZ'dZ(ddhZ)h dZ* e+            Z,de-d<    ej.                    Z/ e	j0        e(          Z1de-d<    ej.                    Z2da3de-d<   e G d d                      Z4dCdZ5dCd Z6dDdEd%Z7dFd'Z8dGd*Z9dHd.Z:dId1Z;dJd5Z<dKd9Z=dLd;Z>dMd=Z?dFd>Z@dFd?ZA G d@ dAe jB                  ZC e jD        eC          ZEdMdBZFdS )Nu
  
Outbound webhook notifications.

Reads the ``hooks.outbound:`` list from ``config.yaml`` and registers
notify-only callbacks on the existing plugin hook manager, so every
``invoke_hook()`` site can push lifecycle events to external HTTP
endpoints — CI systems, dashboards, other agents — with zero changes to
call sites and zero polling on the receiving end.

This is the outbound mirror of the inbound webhook platform
(``gateway/platforms/webhook.py``): inbound wakes Hermes when the world
changes; outbound tells the world when Hermes does something.

Design notes
------------
* Delivery is fire-and-forget through a bounded in-process queue and a
  single daemon worker thread.  ``invoke_hook()`` runs inside the agent
  loop, so callbacks must never block on network I/O — they serialize,
  enqueue, and return ``None`` immediately.  Outbound targets can never
  block a tool call, inject context, or otherwise influence agent flow.
* Payloads are signed with HMAC-SHA256 (GitHub-style
  ``X-Hermes-Signature-256: sha256=<hexdigest>`` over the raw body) when
  a secret is configured.  Receivers verify exactly like they verify
  GitHub webhooks.
* No consent prompt: unlike shell hooks, an outbound target executes no
  code on this machine — it POSTs JSON to a URL the user themselves put
  in config.  ``HERMES_SAFE_MODE=1`` still skips registration, matching
  plugins / MCP / shell hooks.
* Registration is idempotent — safe to invoke from both the CLI entry
  point and the gateway entry point.

Config schema (``~/.hermes/config.yaml``)::

    hooks:
      outbound:
        - url: https://ci.example.com/hermes-events
          events: [on_session_end, subagent_stop]
          # secret literal (discouraged) or env var name (preferred):
          secret_env: HERMES_OUTBOUND_WEBHOOK_SECRET
          # optional regex, honored for pre/post_tool_call only:
          matcher: "terminal|delegate_task"
          timeout: 10       # per-attempt seconds, clamped to [1, 60]
          name: ci-notify   # optional label for logs / `hermes hooks list`

Wire format (POST body)::

    {
        "hook_event_name": "on_session_end",
        "tool_name":       null,
        "tool_input":      null,
        "session_id":      "sess_abc123",
        "cwd":             "/home/user/project",
        "extra":           {...},          # event-specific kwargs
        "delivery_id":     "3f2c...",      # uuid4, unique per POST
        "timestamp":       "2026-07-22T14:00:00Z"
    }

Headers::

    Content-Type:            application/json
    User-Agent:              Hermes-Agent-Outbound-Webhook
    X-Hermes-Event:          <hook event name>
    X-Hermes-Delivery:       <delivery_id>
    X-Hermes-Signature-256:  sha256=<hmac hexdigest>   # only when secret set
    )annotationsN)	dataclassfield)datetimetimezone)Path)AnyDictListOptionalSetTuple)error)request
   <      g      ?   pre_tool_callpost_tool_call>   args	tool_name
session_idparent_session_idzSet[Tuple[str, str]]_registered)maxsizez''queue.Queue[Optional[Dict[str, Any]]]'_delivery_queuezOptional[threading.Thread]_workerc                      e Zd ZU dZded<   ded<   dZded<   dZd	ed
<   dZd	ed<   eZ	ded<    e
dd          Zded<   ddZedd            ZddZdS )WebhookTargetzDParsed and validated representation of one ``hooks.outbound`` entry.strurlz	List[str]events nameNOptional[str]secretmatcherinttimeoutF)defaultreprzOptional[re.Pattern]compiled_matcherreturnNonec                \   t          | j        t                    r$| j                                        }|r|nd | _        | j        rf	 t	          j        | j                  | _        d S # t          j        $ r3}t          	                    d| j        |           d | _        Y d }~d S d }~ww xY wd S )NuL   outbound webhook matcher %r is invalid (%s) — treating as literal equality)

isinstancer(   r!   striprecompiler-   r   loggerwarning)selfstrippedexcs      =/home/thesage/.hermes/hermes-agent/agent/outbound_webhooks.py__post_init__zWebhookTarget.__post_init__|   s    dlC(( 	:|))++H'/988TDL< 	--(*
4<(@(@%%%8 - - -*+/<   )-%%%%%%%-	- 	-s   A' 'B)6(B$$B)c                    | j         p| j        S N)r%   r"   )r7   s    r:   labelzWebhookTarget.label   s    y$DH$    r   boolc                x    | j         sdS |dS | j        | j                            |          d uS || j         k    S )NTF)r(   r-   	fullmatch)r7   r   s     r:   matches_toolzWebhookTarget.matches_tool   sN    | 	45 ,(229==TIIDL((r?   r.   r/   )r.   r!   )r   r&   r.   r@   )__name__
__module____qualname____doc____annotations__r%   r'   r(   DEFAULT_TIMEOUT_SECONDSr*   r   r-   r;   propertyr>   rC    r?   r:   r    r    p   s         NNHHHDNNNN F    !G!!!!*G****-2U4e-L-L-LLLLL- - - - % % % X%) ) ) ) ) )r?   r    cfgOptional[Dict[str, Any]]r.   List[WebhookTarget]c           
        t          | t                    sg S ddlm}  |d          rt                              d           g S |                     d          }t          t          |t                    r|                    d          nd          }|sg S ddlm	}  |            }g }t          5  |D ]}d	}|j        D ]}	|	|j        f}
|
t          v r|j                            |	g                               t#          |	|                     t                              |
           d
}t                              d|	|j        |j        |j                   |r|                    |           	 ddd           n# 1 swxY w Y   |S )u  Register every configured outbound webhook on the plugin manager.

    ``cfg`` is the full parsed config dict.  Missing, empty, or malformed
    ``hooks.outbound`` is treated as zero targets — config parsing never
    raises, because a broken webhook entry must not crash the agent.

    Returns the targets that ended up wired (deduplicated across repeat
    calls, so the CLI and gateway can both invoke this safely).
    r   )env_var_enabledHERMES_SAFE_MODEu<   HERMES_SAFE_MODE=1 — outbound webhook registration skippedhooksoutboundN)get_plugin_managerFTz?outbound webhook registered: %s -> %s (matcher=%s, timeout=%ds))r1   dictutilsrQ   r5   infoget_parse_outbound_blockhermes_cli.pluginsrU   _registered_lockr#   r"   r   _hooks
setdefaultappend_make_callbackaddr>   r(   r*   )rM   rQ   	hooks_cfgtargetsrU   manager
registeredtarget	wired_anyeventkeys              r:   register_from_configrj      s    c4   	%%%%%%)** RSSS	  I#%/	4%@%@J	j!!!d G  	555555  ""G&(J	 * * 	* 	*FI  fj)+%%))%44;;"5&11   $$$ 	#6<   
  *!!&)))#	** * * * * * * * * * * * * * *( s   0CE>>FFc                    t          | t                    sg S |                     d          }t          t          |t                    r|                    d          nd          S )zYParse ``hooks.outbound`` without registering anything.
    Used by ``hermes hooks list``.rS   rT   N)r1   rV   rY   rZ   )rM   rb   s     r:   iter_configured_targetsrl      sa     c4   	  I %/	4%@%@J	j!!!d  r?         @r*   floatr@   c                   t          j                    | z   }t          j                    |k     rnt          j        5  t          j        dk    r	 ddd           dS 	 ddd           n# 1 swxY w Y   t          j        d           t          j                    |k     nt          j        5  t          j        dk    cddd           S # 1 swxY w Y   dS )zBlock until all queued deliveries are done (or *timeout* elapses).
    Returns ``True`` when the queue fully drained.  Test/shutdown helper.r   NTg{Gz?)time	monotonicr   all_tasks_doneunfinished_taskssleep)r*   deadlines     r:   flushrv      sd    ~')H
.

X
%
%+ 	 	/144	 	 	 	 	 	 	 	4	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	
4	 .

X
%
%
 
	' 5 5/145 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5s#   A&&A*-A*(CC	C	r/   c                    t           5  t                                           ddd           n# 1 swxY w Y   	 	 t                                           t                                           3# t          j        $ r Y dS w xY w)zAClear the idempotence set and drain the queue.  Test-only helper.N)r\   r   clearr   
get_nowait	task_donequeueEmptyrL   r?   r:   reset_for_testsr}      s    	                	(&&(((%%'''	( ;   s   .224A. .B Brawr	   c                   | g S t          | t                    s/t                              dt	          |           j                   g S g }t          |           D ],\  }}t          ||          }||                    |           -|S )Nz8hooks.outbound must be a list of webhook targets; got %s)	r1   listr5   r6   typerE   	enumerate_parse_single_targetr_   )r~   rc   ientryrf   s        r:   rZ   rZ      s    
{	c4   FII	
 	
 	
 	#%GcNN # #5%a//NN6"""Nr?   indexr)   Optional[WebhookTarget]c                :   ddl m} t          |t                    s0t                              d| t          |          j                   d S |                    d          }t          |t                    r|
                                st                              d|            d S |
                                }|                                                    d          st                              d| |           d S |                                                    d          rt                              d	|            |                    d
          }t          |t                    r|s>t                              d| d                    t          |                               d S g }|D ]Y}||v r|                    |           t                              d| |d                    t          |                               Z|st                              d|            d S |                    d          }|2t          |t                    st                              d|            d }|7t#          d |D                       st                              d| |           d }|                    dt$                    }	 t'          |          }	n@# t(          t*          f$ r, t                              d| |t$                     t$          }	Y nw xY wt-          dt/          |	t0                              }	t3          | |          }
|                    d          }t          |t                    sd}t5          |||
                                |
||	          S )Nr   )VALID_HOOKSzIhooks.outbound[%d] must be a mapping with 'url' and 'events' keys; got %sr"   z/hooks.outbound[%d] is missing a non-empty 'url')http://zhttps://u:   hooks.outbound[%d].url must be http(s); got %r — skippedr   up   hooks.outbound[%d].url uses plain http:// — payloads (including tool inputs) travel unencrypted. Prefer https.r#   z>hooks.outbound[%d] needs a non-empty 'events' list (valid: %s)z, z8hooks.outbound[%d]: unknown event %r ignored (valid: %s)u2   hooks.outbound[%d] has no valid events — skippedr(   z;hooks.outbound[%d].matcher must be a string regex; ignoringc              3  (   K   | ]}|t           v V  d S r=   )_TOOL_SCOPED_EVENTS).0es     r:   	<genexpr>z'_parse_single_target.<locals>.<genexpr>D  s(      &P&PAq,?'?&P&P&P&P&P&Pr?   um   hooks.outbound[%d].matcher=%r will be ignored — matcher is only honored for pre_tool_call / post_tool_call.r*   zEhooks.outbound[%d].timeout must be an int (got %r); using default %ds   r%   r$   )r"   r#   r%   r'   r(   r*   )r[   r   r1   rV   r5   r6   r   rE   rY   r!   r2   lower
startswithr   joinsortedr_   anyrJ   r)   	TypeError
ValueErrormaxminMAX_TIMEOUT_SECONDS_resolve_secretr    )r   r~   r   r"   
events_rawr#   evr(   timeout_rawr*   r'   r%   s               r:   r   r     s   ......c4   !499#5	
 	
 	
 t
''%..Cc3 syy{{ H%PPPt
))++C99;;!!"9:: H3	
 	
 	
 t
yy{{i(( 
=>C	
 	
 	

 ""Jj$'' z L499VK0011	
 	
 	
 tF  MM"NNJr499VK%8%899     @%	
 	
 	
 tggi  G:gs#;#;I	
 	
 	
 3&P&P&P&P&P#P#P:;@'	
 	
 	
 '')%<==K*k""z" * * * +/F	
 	
 	
 ** !S"56677GUC((F776??DdC   ZZ\\   s   K :LLDict[str, Any]r&   c                   |                     d          }t          |t                    rz|                                rft          j                             |                                d          }|r|S t                              d| |                                           dS |                     d          }t          |t                    r|r|S dS )zE``secret_env`` (env var name, preferred) wins over inline ``secret``.
secret_envr$   u^   hooks.outbound[%d].secret_env=%r is not set in the environment — deliveries will be UNSIGNEDNr'   )rY   r1   r!   r2   osenvironr5   r6   )r   r~   r   valuer'   s        r:   r   r   f  s    &&J*c"" z'7'7'9'9 
z//11266 	L./4j6F6F6H6H	
 	
 	
 tWWXF&# 6 4r?   rh   r!   rf   c                V     d	 fd}d  dj          d|_        |j        |_        |S )
zABuild the notify-only closure ``invoke_hook()`` calls per firing.kwargsr	   r.   r/   c                 j   t           v r*                    |                     d                    sd S t          j                    j        }	 t          | |          }n4# t          $ r' t          	                    dj
        d           Y d S w xY wt          t          ||                     d S )Nr   zBoutbound webhook payload serialization failed (event=%s target=%s)Texc_info)r   rC   rY   uuiduuid4hex_serialize_payload	Exceptionr5   r6   r>   _enqueue_build_delivery)r   delivery_idbodyrh   rf   s      r:   	_callbackz!_make_callback.<locals>._callback  s    '''&&vzz+'>'>?? tjll&	%eV[AADD 	 	 	NN#V\D     44	 	kBBCCCts   A   -BBzoutbound_webhook[:])r   r	   r.   r/   )r>   rE   rG   )rh   rf   r   s   `` r:   r`   r`   |  sX            EUDDV\DDDI&/Ir?   r   r   bytesc                   d |                                 D             }	 t          t          j                              }n# t          $ r d}Y nw xY w| |                    d          t          |                    d          t                    r|                    d          nd|                    d          p|                    d          pd|||t          j	        t          j                                                                      d	d
          d}t          j        |dt                                        d          S )ue  Render the POST body.  Same top-level shape as shell hooks' stdin
    (documented in :mod:`agent.shell_hooks`), plus delivery metadata.

    ``delivery_id`` is shared with the ``X-Hermes-Delivery`` header so
    receivers can dedupe on either — and since it (plus ``timestamp``)
    lives inside the HMAC-signed body, it doubles as replay protection.
    c                ,    i | ]\  }}|t           v||S rL   )_TOP_LEVEL_PAYLOAD_KEYS)r   kvs      r:   
<dictcomp>z&_serialize_payload.<locals>.<dictcomp>  s)    RRRtq!:Q1Q1Qa1Q1Q1Qr?   r$   r   r   Nr   r   )tzz+00:00Z)hook_event_namer   
tool_inputr   cwdextrar   	timestampF)ensure_asciir+   utf-8)itemsr!   r   r   OSErrorrY   r1   rV   r   nowr   utc	isoformatreplacejsondumpsencode)rh   r   r   extrasr   payloads         r:   r   r     s-    SRv||~~RRRF$(**oo    !ZZ,,,6vzz&7I7I4,P,PZfjj(((VZjj..W&**=P2Q2QWUW"\X\222		3		 G :gE3???FFwOOOs    A AAr   c                    dd| |d}|j         rRt          j        |j                             d          |t          j                                                  }d| |d<   |j        |j        | |||j	        dS )Nzapplication/jsonzHermes-Agent-Outbound-Webhook)zContent-Typez
User-AgentzX-Hermes-EventzX-Hermes-Deliveryr   zsha256=zX-Hermes-Signature-256)r"   r>   rh   r   headersr*   )
r'   hmacnewr   hashlibsha256	hexdigestr"   r>   r*   )rh   rf   r   r   r   digests         r:   r   r     s     +5(	 G } ?M  ))4
 

)++ 	 -?f,>,>()z>  r?   deliveryc                    t                       	 t                              |            d S # t          j        $ r2 t
                              dt          | d         | d                    Y d S w xY w)NuE   outbound webhook queue full (%d pending) — dropping %s event for %srh   r>   )_ensure_workerr   
put_nowaitr{   Fullr5   r6   QUEUE_MAX_SIZEr   s    r:   r   r     s    
""8,,,,,: 
 
 
$hw&7'9J	
 	
 	
 	
 	
 	

s   , =A-,A-c                    t           t                                           rd S t          5  t           't                                           r	 d d d            d S t          j        t
          dd          a t                                            t          j        t          d           d d d            d S # 1 swxY w Y   d S )Nzoutbound-webhooksT)rf   r%   daemonrm   r*   )
r   is_alive_worker_lock	threadingThread_worker_loopstartatexitregisterrv   rL   r?   r:   r   r     s   w//11	 , ,7#3#3#5#5, , , , , , , , "&9$
 
 
 	 	s++++, , , , , , , , , , , , , , , , , ,s   "B6AB66B:=B:c                 |   	 t                                           } 	 | t          |            nW# t          $ rJ t                              dt          | t                    r|                     d          ndd           Y nw xY wt                                            n# t                                            w xY w)NTz-outbound webhook delivery crashed (target=%s)r>   ?r   )	r   rY   _deliverr   r5   r6   r1   rV   rz   r   s    r:   r   r     s    ("&&((
	(#""" 	 	 	NN?)3Hd)C)CLW%%%      	 %%''''O%%''''(s'   . B AB?B BB B:c                      e Zd ZdZd ZdS )_NoRedirectHandleruD  Refuse to follow redirects.

    urllib's default handler converts a redirected POST into a body-less
    GET — the signed payload would be silently dropped and the headers
    re-sent to a location the user never configured.  Treat any 3xx as a
    delivery failure instead (surfaced as HTTPError by returning None).
    c                    d S r=   rL   )r7   reqfpcodemsgr   newurls          r:   redirect_requestz#_NoRedirectHandler.redirect_request  s    tr?   N)rE   rF   rG   rH   r   rL   r?   r:   r   r     s-             r?   r   c                ^   d}t          dt          dz             D ]}t          j        | d         | d         | d         d          }	 t                              || d         	          5 }t          |d
d          }ddd           n# 1 swxY w Y   d|cxk    rdk     r/n n,t                              d| d         | d         |            dS d| }n# t          j
        $ r}d|j         }d|j        cxk    rdk     rMn nJt                              d| d         | d         ||j                            dd                     Y d}~ dS d|j        cxk    rdk     r3n n0t                              d| d         | d         |           Y d}~ dS Y d}~n=d}~wt          $ r-}t!          |          pt#          |          j        }Y d}~nd}~ww xY w|t          k     rt'          j        t*          |z             t                              dt          | d         | d         |           dS )u   POST with bounded retries.  Retries on connection errors and 5xx;
    4xx is the receiver telling us the request itself is wrong — no retry.
    3xx redirects are never followed (misconfiguration — fix the URL).r$   r   r"   r   r   POST)datar   methodr*   r   status   Ni,  z.outbound webhook delivered: %s -> %s (HTTP %d)rh   r>   zHTTP i  u{   outbound webhook target redirected (event=%s target=%s): %s -> %s — redirects are not followed; update the configured urlLocationr   i  uC   outbound webhook rejected (event=%s target=%s): %s — not retryingzMoutbound webhook delivery failed after %d attempt(s) (event=%s target=%s): %s)rangeMAX_DELIVERY_ATTEMPTS
urlrequestRequest_openeropengetattrr5   debugurlerror	HTTPErrorr   r6   r   rY   r   r!   r   rE   rp   rt   RETRY_BACKOFF_SECONDS)r   
last_errorattemptr   respr   r9   s          r:   r   r     s    J1A566 &8 &8 UO&!Y'	
 
 
	8c8I+>?? 64 x556 6 6 6 6 6 6 6 6 6 6 6 6 6 6f""""s"""""DW%x'8&   )))JJ! 	 	 	+++Jch$$$$$$$$$%&.w&7'9J
C @ @	   ch$$$$$$$$$#$,W$5x7H  
  	8 	8 	8S7T#YY%7JJJJJJ	8 ***J,w6777
NN	x0(72CZ    sU   	"C+B	=C	B	CB	<CCG%A"F>FG%#GG)rM   rN   r.   rO   )rm   )r*   rn   r.   r@   rD   )r~   r	   r.   rO   )r   r)   r~   r	   r.   r   )r   r)   r~   r   r.   r&   )rh   r!   rf   r    )rh   r!   r   r   r   r!   r.   r   )
rh   r!   rf   r    r   r   r   r!   r.   r   )r   r   r.   r/   )GrH   
__future__r   r   r   r   r   loggingr   r{   r3   r   rp   r   dataclassesr   r   r   r   pathlibr   typingr	   r
   r   r   r   r   urllibr   r  r   r   	getLoggerrE   r5   rJ   r   r   r  r   r   r   setr   rI   Lockr\   Queuer   r   r   r    rj   rl   rv   r}   rZ   r   r   r`   r   r   r   r   r   HTTPRedirectHandlerr   build_openerr  r   rL   r?   r:   <module>r     s  @ @ @D # " " " " "      				  				       ( ( ( ( ( ( ( ( ' ' ' ' ' ' ' '       8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 $ $ $ $ $ $ ( ( ( ( ( (		8	$	$     '(89  SRR  %(CEE ) ) ) )!9>## ;F5;< < <     y~&* * * * * $) $) $) $) $) $) $) $)V3 3 3 3l   
5 
5 
5 
5 
5	 	 	 	    $W W W Wt   ,   0P P P P<   0
 
 
 
, , , ,&( ( ( ( 
 
 
 
 
7 
 
 
 "*
!"4
5
51 1 1 1 1 1r?   