1- """PreToolUse hook callback for Cedar policy enforcement.
1+ """PreToolUse and PostToolUse hook callbacks for policy enforcement.
22
3- Integrates the PolicyEngine with the Claude Agent SDK's hook system
4- to enforce tool-use policies at runtime.
3+ Integrates the PolicyEngine (Cedar, pre-execution) and the output scanner
4+ (regex, post-execution) with the Claude Agent SDK's hook system to enforce
5+ tool-use policies at runtime.
56"""
67
78from __future__ import annotations
89
910import json
1011from typing import TYPE_CHECKING , Any
1112
13+ from output_scanner import scan_tool_output
1214from shell import log
1315
1416if TYPE_CHECKING :
@@ -82,6 +84,70 @@ async def pre_tool_use_hook(
8284 }
8385
8486
87+ async def post_tool_use_hook (
88+ hook_input : Any ,
89+ tool_use_id : str | None ,
90+ hook_context : Any ,
91+ * ,
92+ trajectory : _TrajectoryWriter | None = None ,
93+ ) -> dict :
94+ """PostToolUse hook: screen tool output for secrets/PII.
95+
96+ Returns a dict with hookSpecificOutput. When sensitive content is
97+ detected the response includes ``updatedMCPToolOutput`` containing the
98+ redacted version (steered enforcement — content is sanitized, not
99+ blocked).
100+ """
101+ _PASS_THROUGH : dict = {"hookSpecificOutput" : {"hookEventName" : "PostToolUse" }}
102+ _FAIL_CLOSED : dict = {
103+ "hookSpecificOutput" : {
104+ "hookEventName" : "PostToolUse" ,
105+ "updatedMCPToolOutput" : "[Output redacted: screening error — fail-closed]" ,
106+ }
107+ }
108+
109+ if not isinstance (hook_input , dict ):
110+ log ("WARN" , "PostToolUse hook received non-dict input — passing through" )
111+ return _PASS_THROUGH
112+
113+ tool_name = hook_input .get ("tool_name" , "unknown" )
114+
115+ if "tool_response" not in hook_input :
116+ log ("WARN" , f"PostToolUse hook: missing 'tool_response' key for { tool_name } " )
117+ return _PASS_THROUGH
118+
119+ tool_response = hook_input ["tool_response" ]
120+
121+ # Normalise non-string responses
122+ if not isinstance (tool_response , str ):
123+ tool_response = str (tool_response )
124+
125+ try :
126+ result = scan_tool_output (tool_response )
127+ except Exception as exc :
128+ log ("ERROR" , f"Output scanner failed for { tool_name } : { type (exc ).__name__ } : { exc } " )
129+ if trajectory :
130+ trajectory .write_output_screening_decision (
131+ tool_name , [f"SCANNER_ERROR: { type (exc ).__name__ } " ], redacted = True , duration_ms = 0.0
132+ )
133+ return _FAIL_CLOSED
134+
135+ if result .has_sensitive_content :
136+ if trajectory :
137+ trajectory .write_output_screening_decision (
138+ tool_name , result .findings , redacted = True , duration_ms = result .duration_ms
139+ )
140+ log ("POLICY" , f"OUTPUT REDACTED: { tool_name } — { ', ' .join (result .findings )} " )
141+ return {
142+ "hookSpecificOutput" : {
143+ "hookEventName" : "PostToolUse" ,
144+ "updatedMCPToolOutput" : result .redacted_content ,
145+ }
146+ }
147+
148+ return _PASS_THROUGH
149+
150+
85151def build_hook_matchers (
86152 engine : PolicyEngine ,
87153 trajectory : _TrajectoryWriter | None = None ,
@@ -99,6 +165,7 @@ def build_hook_matchers(
99165 HookInput ,
100166 HookJSONOutput ,
101167 HookMatcher ,
168+ PostToolUseHookSpecificOutput ,
102169 SyncHookJSONOutput ,
103170 )
104171
@@ -110,8 +177,23 @@ async def _pre(
110177 result = await pre_tool_use_hook (
111178 hook_input , tool_use_id , ctx , engine = engine , trajectory = trajectory
112179 )
113- return SyncHookJSONOutput (** result ) # type: ignore[typeddict-item]
180+ return SyncHookJSONOutput (** result )
181+
182+ async def _post (
183+ hook_input : HookInput , tool_use_id : str | None , ctx : HookContext
184+ ) -> HookJSONOutput :
185+ try :
186+ result = await post_tool_use_hook (hook_input , tool_use_id , ctx , trajectory = trajectory )
187+ return SyncHookJSONOutput (** result )
188+ except Exception as exc :
189+ log ("ERROR" , f"PostToolUse wrapper crashed: { type (exc ).__name__ } : { exc } " )
190+ fail_closed : PostToolUseHookSpecificOutput = {
191+ "hookEventName" : "PostToolUse" ,
192+ "updatedMCPToolOutput" : "[Output redacted: hook error — fail-closed]" ,
193+ }
194+ return SyncHookJSONOutput (hookSpecificOutput = fail_closed )
114195
115196 return {
116197 "PreToolUse" : [HookMatcher (matcher = None , hooks = [_pre ])],
198+ "PostToolUse" : [HookMatcher (matcher = None , hooks = [_post ])],
117199 }
0 commit comments