
    |+jK                    P   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m	Z	 ddl
mZmZmZmZ ddlmZ  ej        e          ZdZdZeefZd	Zd9dZd:dZd;dZd<dZd=dZd>dZd?dZd@d ZdAd!Z dBd"Z! G d# d$          Z"d%d%d&dCd)Z#dBd*Z$dDd-Z%d%d%d%d%d.dEd5Z&dFd6Z'dGd8Z(dS )Hu}  Write-approval gate + pending store for memory and skill writes.

Background
----------
The agent writes to two persistent stores that survive across sessions:

  * **memory** — MEMORY.md / USER.md, small (~200 char) declarative entries
  * **skills** — SKILL.md + supporting files, potentially huge (10-100 KB)

Both stores are written from two origins:

  * **foreground** — a normal agent turn (user is present / chatting)
  * **background_review** — the self-improvement review fork that runs after a
    turn and autonomously decides what to save (the source of the
    "wrong assumptions" users complained about)

This module lets the user gate those writes per-subsystem with a boolean
``write_approval``:

  * ``false`` (default) — write freely (the pre-gate behaviour)
  * ``true``            — require approval: do not commit the write; either
    prompt inline (memory, interactive CLI only) or **stage** it to a pending
    store and surface it for the user to approve or reject out-of-band

The size asymmetry between memory and skills is real and unavoidable: a memory
entry can be reviewed inline in a chat bubble; a 100 KB SKILL.md cannot. So
the gate stages BOTH to disk, but review affordances differ by subsystem
(see ``hermes_cli`` slash handlers): memory shows full content, skills show
metadata + a one-line gist + a ``diff`` escape hatch (CLI/dashboard/file).

Staging is mandatory for background-origin writes (a daemon thread cannot
block on an interactive prompt) and for gateway sessions (no inline prompt
channel — review happens via ``/memory pending``). Foreground CLI memory
writes prompt inline via the dangerous-command approval callback; skill
writes always stage (too big to eyeball mid-loop).

Pending records live under ``<HERMES_HOME>/pending/{memory,skills}/<id>.json``
so they survive process restarts and can be reviewed from CLI, gateway, or the
web dashboard.
    )annotationsN)Path)AnyDictListOptionalget_hermes_homememoryskillswrite_approval	subsystemstrreturnboolc                    | t           vrdS 	 ddlm}m}  |            } ||| t          d          }n# t
          $ r Y dS w xY wt          |          S )u'  Return whether the approval gate is enabled for ``subsystem``.

    Reads ``<subsystem>.write_approval`` from config.yaml. Defaults to
    ``False`` (gate off — writes flow freely) for any unset / invalid value so
    existing installs keep their current behaviour until the user opts in.
    Fr   )load_configcfg_get)default)_SUBSYSTEMShermes_cli.configr   r   
CONFIG_KEY	Exception_normalize_enabled)r   r   r   cfgraws        3/usr/local/lib/hermes-agent/tools/write_approval.pywrite_approval_enabledr   J   s     ##u::::::::kmmgc9j%@@@   uuc"""s   &4 
AAvaluer   c                    t          | t                    r| S t          | t                    r(|                                                                 dv S dS )a  Coerce a config value to a bool. Default (unknown) is False (gate off).

    Accepts real bools and the usual truthy/falsey strings. YAML 1.1 parses
    bare ``on``/``off``/``yes``/``no`` as bools already, so the string branch
    is mostly for hand-edited configs.
    >   1onyestrueapproveenabledF)
isinstancer   r   striplower)r   s    r   r   r   \   sS     % % Y{{}}""$$(XXX5    r   c                *    t                      dz  | z  S )Npendingr	   )r   s    r   _pending_dirr-   n   s    y(944r*   payloadDict[str, Any]summaryoriginc               F   t          j                    j        dd         }|| |                    dd          |pd                                |pdt          j                    |d}	 t          |           }|                    dd           || d	z  }|                    d
          }|	                    t          j        |dd          d           t          j        ||           n5# t          $ r(}	t                              d| |	d           Y d}	~	nd}	~	ww xY w|S )uO  Persist a pending write and return a short record describing it.

    Args:
        subsystem: ``memory`` or ``skills``.
        payload: the exact kwargs needed to replay the write when approved
            (e.g. ``{"action": "add", "target": "user", "content": "..."}``
            for memory, or the full ``skill_manage`` kwargs for skills).
        summary: a one-line human-readable description shown in pending lists.
            For skills this is the LLM/heuristic gist; for memory it can be the
            entry text itself.
        origin: ``foreground`` or ``background_review`` — recorded for audit.

    Returns a dict with ``id`` and metadata. Best-effort: on disk failure it
    logs and still returns a record (the write is simply lost, which is the
    safe failure for an approval gate — nothing is silently committed).
    N   action 
foreground)idr   r4   r0   r1   
created_atr.   T)parentsexist_ok.jsonz	.json.tmpF   )ensure_asciiindentutf-8encodingz$Failed to stage pending %s write: %s)exc_info)uuiduuid4hexgetr(   timer-   mkdirwith_suffix
write_textjsondumpsosreplacer   loggererror)
r   r.   r0   r1   pidrecorddpathtmpes
             r   stage_writerW   r   sA   $ *,,
2A2
C++h++Mr((**(Likk FZ##	t,,,c=== {++tz&uQGGGRYZZZ

3 Z Z Z;YTXYYYYYYYYZMs   'BC, ,
D6DDList[Dict[str, Any]]c                   t          |           }|                                sg S g }|                    d          D ]i}	 |                    t	          j        |                    d                               ?# t          $ r t          	                    d|           Y fw xY w|
                    d            |S )z;Return all pending records for ``subsystem``, oldest first.*.jsonr?   r@   z&Skipping unreadable pending record: %sc                .    |                      dd          S )Nr8   r   )rF   )rs    r   <lambda>zlist_pending.<locals>.<lambda>   s    quu\155 r*   )key)r-   existsglobappendrK   loads	read_textr   rO   warningsort)r   rS   recordsps       r   list_pendingrh      s    YA88:: 	$&GVVH H H	HNN4:akk7k&C&CDDEEEE 	H 	H 	HNNCQGGGGG	HLL55L666Ns    ;A<<%B$#B$
pending_idOptional[Dict[str, Any]]c                    t          |           | dz  }|                                sdS 	 t          j        |                    d                    S # t
          $ r Y dS w xY w)z.Return a single pending record by id, or None.r;   Nr?   r@   )r-   r_   rK   rb   rc   r   )r   ri   rT   s      r   get_pendingrl      sw    	""
%9%9%99D;;== tz$..'.::;;;   tts   'A 
A#"A#c                    t          |           | dz  }	 |                                r|                                 dS n4# t          $ r'}t                              d| ||           Y d}~nd}~ww xY wdS )z4Delete a pending record. Returns True if it existed.r;   Tz#Failed to discard pending %s/%s: %sNF)r-   r_   unlinkr   rO   rP   )r   ri   rT   rV   s       r   discard_pendingro      s    	""
%9%9%99DV;;== 	KKMMM4	  V V V:IzSTUUUUUUUUV5s   (A 
A3A..A3intc                    t          |           }|                                sdS 	 t          d |                    d          D                       S # t          $ r Y dS w xY w)z9Cheap count of pending records (for notification badges).r   c              3     K   | ]}d V  dS )   N ).0_s     r   	<genexpr>z pending_count.<locals>.<genexpr>   s"      //1//////r*   rZ   )r-   r_   sumr`   r   )r   rS   s     r   pending_country      st    YA88:: q//affX..//////   qqs   +A 
A! A!c                 F    	 ddl m}   |             S # t          $ r Y dS w xY w)a=  Return the active write origin: ``foreground`` or ``background_review``.

    Reuses the skill-provenance ContextVar, which the background review fork
    already sets (see ``agent.background_review`` /
    ``AIAgent._spawn_background_review``). Foreground agent turns leave it at
    the default ``foreground``.
    r   get_current_write_originr6   )tools.skill_provenancer|   r   r{   s    r   current_originr~      sJ    CCCCCC'')))   ||s    
  c                 &    t                      dk    S )Nbackground_review)r~   rt   r*   r   is_backgroundr      s    222r*   c                  (    e Zd ZdZdZddddddZdS )GateDecisionu  Result of evaluating the write gate for a single write attempt.

    Exactly one of the boolean flags is True:
      * ``allow``  — proceed with the real write (gate off, or an inline
        approval was granted).
      * ``blocked`` — refuse the write (the user denied an inline approval
        prompt). ``message`` explains why; surface it to the agent.
      * ``stage``  — do not write; the caller should stage the payload via
        ``stage_write`` (gate on, and no inline prompt is available — gateway,
        background review, script, or any skill write). ``message`` is the
        user-facing "staged for approval" note.
    allowblockedstagemessageFr5   c               >    || _         || _        || _        || _        d S )Nr   )selfr   r   r   r   s        r   __init__zGateDecision.__init__   s"    

r*   N)__name__
__module____qualname____doc__	__slots__r   rt   r*   r   r   r      sG          9I %uE2       r*   r   r5   )inline_summaryinline_detailr   r   c               t   t          |           st          d          S t                      }| t          k    s|r'| t          k    rdnd}t          dd|  d| d          S t	                      r9t          ||          }|du rt          d          S |d	u rt          dd
          S t          dd          S )u  Decide what to do with a pending write for ``subsystem``.

    Args:
        subsystem: ``memory`` or ``skills``.
        inline_summary: short description used as the inline approval prompt
            header (memory foreground path only).
        inline_detail: full content shown in the inline prompt (memory entries
            are small; skills never take the inline path).

    Decision matrix:
        gate off (default)                    → allow (writes flow freely)
        gate on, memory + interactive CLI     → inline approve/deny prompt
        gate on, memory + gateway/script/bg   → stage
        gate on, skills (any origin)          → stage (too big to review inline)

    Note: there is no config-driven "blocked" outcome — the gate only ever
    delays a write for approval, never silently refuses it. ``blocked`` is
    still produced when the user *actively denies* an inline prompt.
    T)r   z/skills pendingz/memory pendingzStaged for approval (u6   .write_approval is on). Not yet saved — review with .)r   r   Fz6Memory write denied by user. The change was not saved.)r   r   ua   Staged for approval (memory.write_approval is on). Not yet saved — review with /memory pending.)r   r   r   SKILLS_interactive_approval_available_prompt_inline_memory_approval)r   r   r   
backgroundwheregranteds         r   evaluate_gater      s   * "),, ($''''J Fj%.&%8%8!!>O:	 : :16: : :
 
 
 	
 '(( 0OOd??d++++eP    =   r*   c                 J    	 ddl m}   |             duS # t          $ r Y dS w xY w)u  True when a foreground memory write can be approved inline.

    Inline prompting requires a per-thread approval callback registered by the
    interactive CLI (``tools.terminal_tool.set_approval_callback``). Every
    other surface stages instead:

    * **Gateway/API sessions** — the dangerous-command ``/approve`` round-trip
      lives in the pending-approval queue (``submit_pending`` +
      ``_await_gateway_decision``), which ``prompt_dangerous_approval`` never
      reaches; trying to prompt from a gateway session would hit the
      ``input()`` fallback and silently deny. Staging gives the user a real
      review affordance (``/memory pending``) instead.
    * Scripts, cron, and background threads — no user present.
    r   _get_approval_callbackNF)tools.terminal_toolr   r   r   s    r   r   r   ;  sO    >>>>>>%%''t33   uus    
""detailOptional[bool]c                `   	 ddl m} n# t          $ r Y dS w xY w |            }|dS |                                 pd}|                                }d| }|r|n|}	  |||d          }n3# t          $ r&}	t                              d|	           Y d}	~	dS d}	~	ww xY w|d	v rd
S |dk    rdS dS )uo  Prompt the user inline to approve a memory write.

    Returns True (approved), False (denied), or None (no interactive prompt
    available / prompt failed → caller should stage instead).

    Reuses the per-thread CLI approval callback registered for dangerous
    commands (``tools.terminal_tool.set_approval_callback``). The callback is
    invoked directly — NOT via ``prompt_dangerous_approval`` — because that
    wrapper falls back to ``input()`` (deadlock-prone under prompt_toolkit,
    see #15216) and converts callback errors into a silent deny; here a
    failed prompt must stage the write instead.
    r   r   NzSave to memory?zSave to memory: F)allow_permanentz(Inline memory approval prompt failed: %s>   oncesessionTdeny)r   r   r   r(   rO   rP   )
r0   r   r   callbackheaderbodydescriptioncommandchoicerV   s
             r   r   r   Q  s   >>>>>>>   tt &%''H t]]__1 1F<<>>D-V--K&ddG
';FFF   ?CCCttttt $$$tu 4s$   	 
A- -
B7BB)content	file_path
old_string
new_stringr4   namer   r   r   r   c          	        | dv rs|rqt          |          }t          |          dk    rt          |          dz  dz    dnt          |           d}| dk    rdnd}|r| d| d	| d
| dS | d| d| dS | dk    rK|pd}	|r|                    d          dz   nd}
|r|                    d          dz   nd}d| d|	 d| d|
 d	S | dk    r	d| d| dS | dk    r	d| d| dS | dk    rd| dS |  d| dS )ug  Build a one-line human gist for a pending skill write.

    Heuristic, no model call — the gist surfaces enough to decide approve/reject
    in a chat bubble, while the full diff stays behind /skills diff (CLI/
    dashboard/file). For create/edit it pulls the frontmatter ``description:``;
    for patch/write_file it describes the size of the change.
    >   editcreatei   rs   z KBz charsr   rewritez 'u   ' — z ()z' (patchSKILL.md
r   zpatch 'z' z (+z/-z lines)
write_filezwrite z in ''remove_filezremove z from 'deletedelete skill ')_frontmatter_descriptionlencount)r4   r   r   r   r   r   descsizeverbtargetremovedaddeds               r   
skill_gistr     s    ####'0036w<<43G3G#g,,$&*////PST[P\P\MdMdMd!X--xx9 	:99d99$99$9999**$**4****(j0:A*""4((1,,.8?
  &&**aEEEEEEEEWEEEE/	//////22242222'''''r*   c                    ddl }|                    d| |j                  }|sdS |                    d                                                              d          }|dd         S )zBExtract the ``description:`` value from SKILL.md YAML frontmatter.r   Nz^description:\s*(.+)$r5   rs   z'"   )research	MULTILINEgroupr(   )r   r   mr   s       r   r   r     sg    III
		*GR\BBA r771::##E**D:r*   rR   c                h   ddl }|                     di           }|                    dd          }|                    dd          }|dk    r|                    d          pdS 	 dd	lm} n# t          $ r d}Y nw xY wd}d
}| ||          }|rz|d         }	|dk    r|	d
z  }
n(|dv r|                    d          pd
}|	|z  }
|}n|	d
z  }
	 |
                                r|
                    d          }n# t          $ r d}Y nw xY w|dk    r|                    d          pd}n|dk    rP|                    d          pd}|                    d          pd}|r|                    ||          nd|d|d}nU|dk    r|                    d          pd}n7|dk    rd|                    d           d| dS |dk    rd| dS d| d | d!S |                    |	                    d"#          |	                    d"#          d$| d%| &          }d
                    |          }|pd'S )(aF  Build a full unified diff (or full content) for a staged skill write.

    Used by /skills diff <id> on a surface that can render it (CLI pager, web
    dashboard, or by opening the pending JSON file). For create this is the new
    file content; for edit/patch it is a unified diff against the current
    on-disk skill.
    r   Nr.   r4   r5   r   r   r   )_find_skillr   rT   r   >   r   r   r   r?   r@   r   r   r   z(patch u    → r   r   file_contentr   zremove file: z from skill 'r   r   r   (z on 'z')T)keependsza/zb/)fromfiletofilez(no textual change))difflibrF   tools.skill_manager_toolr   r   r_   rc   rN   unified_diff
splitlinesjoin)rR   r   r.   r4   r   r   currenttarget_labelfoundbaserg   relnewold_snew_sdifftexts                    r   skill_pending_diffr     s    NNNjjB''G[[2&&F;;vr""DI&&,"-8888888    GLD!! 	=D:%222kk+..<*3J":%88:: <kk7k;;G    kk)$$*	7		L))/RL))/R/6^gooeU+++<^e<^<^TY<^<^<^	<		kk.))/R	=	 	 Mw{{;77MMdMMMM	8		'''''(6((((((D))%%$l$$"L""	    D 774==D(((s$   %A, ,A;:A;*C9 9DD)r   r   r   r   )r   r   r   r   )r   r   r   r   )
r   r   r.   r/   r0   r   r1   r   r   r/   )r   r   r   rX   )r   r   ri   r   r   rj   )r   r   ri   r   r   r   )r   r   r   rp   )r   r   )r   r   )r   r   r   r   r   r   r   r   )r0   r   r   r   r   r   )r4   r   r   r   r   r   r   r   r   r   r   r   r   r   )r   r   r   r   )rR   r/   r   r   ))r   
__future__r   rK   loggingrM   rG   rC   pathlibr   typingr   r   r   r   hermes_constantsr
   	getLoggerr   rO   MEMORYr   r   r   r   r   r-   rW   rh   rl   ro   ry   r~   r   r   r   r   r   r   r   r   rt   r*   r   <module>r      sl  ' 'R # " " " " "   				         , , , , , , , , , , , , , , , , , ,		8	$	$ 
	v 
# # # #$   $5 5 5 5% % % %P      	 	 	 	      3 3 3 3       . <>'); ; ; ; ; ;|   ,, , , ,f :< "b!#           >   @) @) @) @) @) @)r*   