agent-petri-dish 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CONTRIBUTING.md +36 -0
- data/LICENSE +21 -0
- data/README.md +145 -0
- data/bin/petri-dish +6 -0
- data/hooks/event-logger.sh +19 -0
- data/hooks/permission-handler.sh +35 -0
- data/lib/petri-dish.rb +3 -0
- data/lib/petri_dish/cli.rb +214 -0
- data/lib/petri_dish/config.rb +75 -0
- data/lib/petri_dish/environment.rb +146 -0
- data/lib/petri_dish/hook_log.rb +174 -0
- data/lib/petri_dish/preambles/escape-hatch.md +27 -0
- data/lib/petri_dish/preambles/guidance.md +14 -0
- data/lib/petri_dish/preambles/permissions.md +25 -0
- data/lib/petri_dish/preambles/sandbox.md +28 -0
- data/lib/petri_dish/results_builder.rb +127 -0
- data/lib/petri_dish/runner.rb +330 -0
- data/lib/petri_dish/transcript.rb +37 -0
- data/lib/petri_dish/version.rb +5 -0
- data/lib/petri_dish.rb +19 -0
- data/scripts/analyze-hooks.py +114 -0
- data/scripts/hook-block-pattern.sh +49 -0
- data/scripts/hook-logger.sh +18 -0
- data/scripts/inspect-session.sh +51 -0
- data/scripts/migrate-configs.rb +73 -0
- data/scripts/migrate-prompts.rb +75 -0
- metadata +70 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
require "tempfile"
|
|
7
|
+
require_relative "config"
|
|
8
|
+
require_relative "environment"
|
|
9
|
+
require_relative "hook_log"
|
|
10
|
+
require_relative "results_builder"
|
|
11
|
+
require_relative "transcript"
|
|
12
|
+
|
|
13
|
+
module PetriDish
|
|
14
|
+
class StartupFailure < StandardError; end
|
|
15
|
+
class AuthFailure < StandardError; end
|
|
16
|
+
|
|
17
|
+
class Runner
|
|
18
|
+
POLL_INTERVAL = 2 # seconds
|
|
19
|
+
STARTUP_DEADLINE = 60 # seconds, must see a SessionStart event by then or fail loud
|
|
20
|
+
AUTH_ERROR_PATTERN = %r{API Error: 40[13]|Please run /login|Invalid authentication credentials|authentication_error}
|
|
21
|
+
|
|
22
|
+
def initialize(test_name, cultures_dir:, results_dir:, deny: false, debug: false, keep: false)
|
|
23
|
+
@cultures_dir = cultures_dir
|
|
24
|
+
@results_dir = results_dir
|
|
25
|
+
@test_dir = File.join(cultures_dir, test_name)
|
|
26
|
+
@config = Config.new(@test_dir)
|
|
27
|
+
@deny = deny
|
|
28
|
+
@debug = debug
|
|
29
|
+
@keep = keep
|
|
30
|
+
@tmux_session = "test-#{@config.name}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run!
|
|
34
|
+
preflight!
|
|
35
|
+
ensure_env!
|
|
36
|
+
run_prepare!
|
|
37
|
+
|
|
38
|
+
env = Environment.new(@config.environment[:name])
|
|
39
|
+
|
|
40
|
+
# Re-trust after prepare. The setup-time trust call runs before the
|
|
41
|
+
# work_dir exists, so File.realpath falls back to expand_path and stores
|
|
42
|
+
# the trust key under /tmp/... — but Claude resolves symlinks and looks
|
|
43
|
+
# under /private/tmp/... on macOS. Re-trusting here, with the path now
|
|
44
|
+
# existing, lets realpath canonicalize properly.
|
|
45
|
+
env.trust!(@config.runtime[:work_dir])
|
|
46
|
+
|
|
47
|
+
results_dir = create_results_dir
|
|
48
|
+
signal_file = File.join(results_dir, "signal")
|
|
49
|
+
transcript_path = File.join(results_dir, "transcript.log")
|
|
50
|
+
|
|
51
|
+
env.clear_hook_log!
|
|
52
|
+
|
|
53
|
+
prompt = build_prompt(signal_file)
|
|
54
|
+
launch_tmux!(prompt)
|
|
55
|
+
|
|
56
|
+
failure = nil
|
|
57
|
+
begin
|
|
58
|
+
poll_for_completion(signal_file, env)
|
|
59
|
+
rescue Interrupt
|
|
60
|
+
log "Interrupted by user"
|
|
61
|
+
rescue StartupFailure, AuthFailure => e
|
|
62
|
+
failure = e
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Capture transcript first. If we failed, tmux may be dying and we
|
|
66
|
+
# want whatever output Claude printed (auth error, version mismatch, etc.).
|
|
67
|
+
transcript = Transcript.new(@tmux_session)
|
|
68
|
+
transcript.save!(transcript_path) if tmux_alive?
|
|
69
|
+
|
|
70
|
+
# Collect artifacts
|
|
71
|
+
hook_log_dest = File.join(results_dir, "hook-events.jsonl")
|
|
72
|
+
FileUtils.cp(env.hook_log_path, hook_log_dest) if File.exist?(env.hook_log_path)
|
|
73
|
+
|
|
74
|
+
if failure
|
|
75
|
+
# Tear down tmux now so the error message isn't competing with a live session.
|
|
76
|
+
system("tmux kill-session -t #{@tmux_session} 2>/dev/null")
|
|
77
|
+
report_failure!(failure, transcript_path, results_dir)
|
|
78
|
+
exit 1
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Build results
|
|
82
|
+
if File.exist?(hook_log_dest)
|
|
83
|
+
builder = ResultsBuilder.new(hook_log_dest, transcript_path, results_dir)
|
|
84
|
+
builder.build!
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Teardown
|
|
88
|
+
unless @keep
|
|
89
|
+
system("tmux kill-session -t #{@tmux_session} 2>/dev/null")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Summary
|
|
93
|
+
results_file = File.join(results_dir, "results.md")
|
|
94
|
+
log ""
|
|
95
|
+
log "Run complete: #{@config.name}"
|
|
96
|
+
log " Results: #{File.exist?(results_file) ? results_file : '(not written)'}"
|
|
97
|
+
log " Hook log: #{File.exist?(hook_log_dest) ? hook_log_dest : '(not captured)'}"
|
|
98
|
+
log " Transcript: #{File.exist?(transcript_path) ? transcript_path : '(not captured)'}"
|
|
99
|
+
if @keep
|
|
100
|
+
log " tmux: tmux attach -t #{@tmux_session}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def run_prepare!
|
|
107
|
+
commands = @config.runtime[:prepare] || []
|
|
108
|
+
return if commands.empty?
|
|
109
|
+
|
|
110
|
+
log "Running #{commands.length} prepare step(s)"
|
|
111
|
+
commands.each do |cmd|
|
|
112
|
+
log " prepare: #{cmd}"
|
|
113
|
+
raise "prepare step failed: #{cmd}" unless system(cmd)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def preflight!
|
|
118
|
+
%w[claude tmux].each do |cmd|
|
|
119
|
+
unless system("command -v #{cmd} > /dev/null 2>&1")
|
|
120
|
+
raise "Required command not found: #{cmd}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def ensure_env!
|
|
126
|
+
env = Environment.new(@config.environment[:name])
|
|
127
|
+
unless env.exists?
|
|
128
|
+
raise "Environment '#{@config.environment[:name]}' not found. Run: petri-dish setup #{@config.name}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def create_results_dir
|
|
133
|
+
timestamp = Time.now.strftime("%Y-%m-%dT%H-%M-%S")
|
|
134
|
+
base = File.join(@results_dir, @config.name, timestamp)
|
|
135
|
+
# Guard against collisions: fast cultures (~20-40s) can finish multiple
|
|
136
|
+
# runs in the same second. Append a counter so a later run never clobbers
|
|
137
|
+
# an earlier one's dir (and races its half-written hook-events.jsonl).
|
|
138
|
+
dir = base
|
|
139
|
+
counter = 1
|
|
140
|
+
while File.exist?(dir)
|
|
141
|
+
counter += 1
|
|
142
|
+
dir = "#{base}-#{counter}"
|
|
143
|
+
end
|
|
144
|
+
FileUtils.mkdir_p(dir)
|
|
145
|
+
dir
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_prompt(signal_file)
|
|
149
|
+
parts = []
|
|
150
|
+
|
|
151
|
+
# Preamble
|
|
152
|
+
if @config.runtime[:preamble]
|
|
153
|
+
preamble_path = resolve_preamble(@config.runtime[:preamble])
|
|
154
|
+
if preamble_path
|
|
155
|
+
parts << File.read(preamble_path)
|
|
156
|
+
parts << "\n---\n"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Main prompt
|
|
161
|
+
prompt_path = @config.prompt_path
|
|
162
|
+
raise "Prompt not found: #{prompt_path}" unless prompt_path.exist?
|
|
163
|
+
parts << prompt_path.read
|
|
164
|
+
|
|
165
|
+
# Part suffix
|
|
166
|
+
if @config.runtime[:part_suffix]
|
|
167
|
+
parts << "\n---\n"
|
|
168
|
+
parts << "**INSTRUCTION: #{@config.runtime[:part_suffix]}**"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Signal file injection
|
|
172
|
+
if @config.runtime[:inject_results_file]
|
|
173
|
+
parts << "\n---\n"
|
|
174
|
+
parts << "**SIGNAL_FILE: #{signal_file}**"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
parts.join("\n")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def launch_tmux!(prompt)
|
|
181
|
+
system("tmux kill-session -t #{@tmux_session} 2>/dev/null")
|
|
182
|
+
|
|
183
|
+
work_dir = @config.runtime[:work_dir]
|
|
184
|
+
env_dir = Environment.new(@config.environment[:name]).env_path
|
|
185
|
+
|
|
186
|
+
prompt_file = Tempfile.new(["petri-dish-prompt-", ".md"], Dir.tmpdir)
|
|
187
|
+
prompt_file.write(prompt)
|
|
188
|
+
prompt_file.close
|
|
189
|
+
|
|
190
|
+
model_flag = @config.runtime[:model] ? " --model #{@config.runtime[:model]}" : ""
|
|
191
|
+
launcher = Tempfile.new(["petri-dish-launcher-", ".sh"], Dir.tmpdir)
|
|
192
|
+
launcher.write(<<~SH)
|
|
193
|
+
#!/usr/bin/env bash
|
|
194
|
+
exec env CLAUDE_CONFIG_DIR='#{env_dir}' ENABLE_CLAUDEAI_MCP_SERVERS='false' claude#{model_flag} "$(cat '#{prompt_file.path}')"
|
|
195
|
+
SH
|
|
196
|
+
launcher.close
|
|
197
|
+
File.chmod(0o755, launcher.path)
|
|
198
|
+
|
|
199
|
+
@_prompt_file = prompt_file
|
|
200
|
+
@_launcher = launcher
|
|
201
|
+
|
|
202
|
+
system("tmux new-session -d -s #{@tmux_session} -c #{work_dir.shellescape}")
|
|
203
|
+
system("tmux send-keys -t #{@tmux_session} #{launcher.path.shellescape} Enter")
|
|
204
|
+
|
|
205
|
+
log "Claude launched in tmux session '#{@tmux_session}'"
|
|
206
|
+
log "Attach to watch: tmux attach -t #{@tmux_session}"
|
|
207
|
+
|
|
208
|
+
sleep 3
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def poll_for_completion(signal_file, env)
|
|
212
|
+
timeout = @config.runtime[:timeout]
|
|
213
|
+
start = Time.now
|
|
214
|
+
hook_log_lines_seen = 0
|
|
215
|
+
startup_confirmed = false
|
|
216
|
+
|
|
217
|
+
log "Polling for completion (timeout: #{timeout}s)"
|
|
218
|
+
|
|
219
|
+
loop do
|
|
220
|
+
unless tmux_alive?
|
|
221
|
+
unless startup_confirmed
|
|
222
|
+
raise StartupFailure, "tmux session exited before any SessionStart hook event fired"
|
|
223
|
+
end
|
|
224
|
+
break
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
if File.exist?(signal_file) && File.size(signal_file) > 0
|
|
228
|
+
log "Signal file detected. Session complete."
|
|
229
|
+
break
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Watch for SessionStart in the hook log, firmest signal that Claude
|
|
233
|
+
# actually booted (vs. exited on auth failure / version mismatch / etc.).
|
|
234
|
+
if !startup_confirmed && File.exist?(env.hook_log_path)
|
|
235
|
+
if File.read(env.hook_log_path).include?('"hook_event_name":"SessionStart"')
|
|
236
|
+
startup_confirmed = true
|
|
237
|
+
log "Session started (SessionStart event observed)"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Once booted, watch the visible pane for mid-session auth failures.
|
|
242
|
+
# Claude fires SessionStart before its first API call, so an OAuth-stale
|
|
243
|
+
# session can confirm startup, hit 401/403, then sit waiting for input.
|
|
244
|
+
if startup_confirmed && (match = AUTH_ERROR_PATTERN.match(Transcript.new(@tmux_session).capture_visible))
|
|
245
|
+
raise AuthFailure, "API auth error mid-session: #{match[0]}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
elapsed = Time.now - start
|
|
249
|
+
if !startup_confirmed && elapsed >= STARTUP_DEADLINE
|
|
250
|
+
raise StartupFailure, "no SessionStart event within #{STARTUP_DEADLINE}s (tmux still alive, Claude may be stuck at a login prompt)"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
if elapsed >= timeout
|
|
254
|
+
log "Timeout reached (#{timeout}s). Stopping."
|
|
255
|
+
break
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Debug: print new hook events as they arrive
|
|
259
|
+
if @debug && File.exist?(env.hook_log_path)
|
|
260
|
+
lines = File.readlines(env.hook_log_path)
|
|
261
|
+
lines[hook_log_lines_seen..].each do |line|
|
|
262
|
+
data = JSON.parse(line) rescue next
|
|
263
|
+
p = data["payload"]
|
|
264
|
+
event = p["hook_event_name"]
|
|
265
|
+
tool = p["tool_name"] || ""
|
|
266
|
+
cmd = p.dig("tool_input", "command") || ""
|
|
267
|
+
$stderr.puts "\e[2m[hook] #{event} #{tool} #{cmd[0..60]}\e[0m"
|
|
268
|
+
end
|
|
269
|
+
hook_log_lines_seen = lines.size
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
sleep POLL_INTERVAL
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def report_failure!(err, transcript_path, results_dir)
|
|
277
|
+
label = err.is_a?(AuthFailure) ? "AUTH FAILURE" : "STARTUP FAILURE"
|
|
278
|
+
marker_file = err.is_a?(AuthFailure) ? "AUTH_FAILURE.txt" : "STARTUP_FAILURE.txt"
|
|
279
|
+
|
|
280
|
+
$stderr.puts ""
|
|
281
|
+
$stderr.puts "\e[31m[runner] #{label}\e[0m"
|
|
282
|
+
$stderr.puts " #{err.message}"
|
|
283
|
+
$stderr.puts ""
|
|
284
|
+
$stderr.puts " Likely causes:"
|
|
285
|
+
$stderr.puts " - Stale OAuth credentials in the cenv environment."
|
|
286
|
+
$stderr.puts " - Claude Code version installed for this env can't auth."
|
|
287
|
+
$stderr.puts " - claude binary missing from PATH inside tmux."
|
|
288
|
+
$stderr.puts ""
|
|
289
|
+
$stderr.puts " Recovery:"
|
|
290
|
+
$stderr.puts " petri-dish setup --clean #{@config.name}"
|
|
291
|
+
$stderr.puts " petri-dish setup #{@config.name}"
|
|
292
|
+
$stderr.puts " cenv login #{@config.environment[:name]} # if a /login prompt was shown"
|
|
293
|
+
$stderr.puts ""
|
|
294
|
+
if File.exist?(transcript_path) && File.size(transcript_path) > 0
|
|
295
|
+
$stderr.puts " Captured tmux output: #{transcript_path}"
|
|
296
|
+
last = File.read(transcript_path).lines.last(20).join
|
|
297
|
+
$stderr.puts " --- last 20 lines ---"
|
|
298
|
+
$stderr.puts last.gsub(/^/, " ")
|
|
299
|
+
else
|
|
300
|
+
$stderr.puts " No tmux output captured (session may have died before printing)."
|
|
301
|
+
end
|
|
302
|
+
$stderr.puts ""
|
|
303
|
+
# Drop a marker so the empty results dir is self-explanatory later.
|
|
304
|
+
File.write(File.join(results_dir, marker_file), "#{err.message}\n")
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def resolve_preamble(name)
|
|
308
|
+
# Legacy configs use paths like "lib/preambles/sandbox.md". Strip the prefix
|
|
309
|
+
# so the bare name maps to gem-bundled lib/petri_dish/preambles/<name>.md, but
|
|
310
|
+
# still allow user-provided overrides in the cultures_dir.
|
|
311
|
+
bare = name.sub(%r{\Alib/preambles/}, "")
|
|
312
|
+
|
|
313
|
+
candidates = [
|
|
314
|
+
File.join(@cultures_dir, name),
|
|
315
|
+
File.join(@cultures_dir, bare),
|
|
316
|
+
File.join(PetriDish.root, "lib", "petri_dish", "preambles", bare)
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
candidates.find { |p| File.exist?(p) }
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def tmux_alive?
|
|
323
|
+
system("tmux has-session -t #{@tmux_session} 2>/dev/null")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def log(msg)
|
|
327
|
+
puts "\e[32m[runner]\e[0m #{msg}"
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PetriDish
|
|
4
|
+
class Transcript
|
|
5
|
+
ANSI_REGEX = /\e\[[0-9;]*[a-zA-Z]/
|
|
6
|
+
|
|
7
|
+
def initialize(tmux_session)
|
|
8
|
+
@tmux_session = tmux_session
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def capture_pane(visible_only: false)
|
|
12
|
+
flag = visible_only ? "" : "-S -"
|
|
13
|
+
raw = `tmux capture-pane -t #{@tmux_session} -p #{flag} 2>/dev/null`
|
|
14
|
+
strip_ansi(raw)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def capture_visible
|
|
18
|
+
capture_pane(visible_only: true)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def save!(output_path)
|
|
22
|
+
content = capture_pane
|
|
23
|
+
File.write(output_path, content)
|
|
24
|
+
log "Transcript saved to #{output_path}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def strip_ansi(text)
|
|
30
|
+
text.gsub(ANSI_REGEX, "")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def log(msg)
|
|
34
|
+
puts "\e[32m[transcript]\e[0m #{msg}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/petri_dish.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "petri_dish/version"
|
|
4
|
+
|
|
5
|
+
module PetriDish
|
|
6
|
+
# Root directory of the installed gem. Used to locate ship-with assets
|
|
7
|
+
# (hooks, preambles). Distinct from user-provided cultures_dir and results_dir.
|
|
8
|
+
def self.root
|
|
9
|
+
File.expand_path("..", __dir__)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
require_relative "petri_dish/config"
|
|
14
|
+
require_relative "petri_dish/environment"
|
|
15
|
+
require_relative "petri_dish/hook_log"
|
|
16
|
+
require_relative "petri_dish/results_builder"
|
|
17
|
+
require_relative "petri_dish/runner"
|
|
18
|
+
require_relative "petri_dish/transcript"
|
|
19
|
+
require_relative "petri_dish/cli"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Analyze hook event log and correlate with sidecar results."""
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
def parse_ts(ts_str):
|
|
8
|
+
"""Parse ISO timestamp to datetime."""
|
|
9
|
+
# Handle both millisecond and second precision
|
|
10
|
+
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
|
|
11
|
+
try:
|
|
12
|
+
return datetime.strptime(ts_str, fmt)
|
|
13
|
+
except ValueError:
|
|
14
|
+
continue
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
def main():
|
|
18
|
+
hook_file = sys.argv[1] if len(sys.argv) > 1 else "/tmp/petri-dish-hooks.jsonl"
|
|
19
|
+
|
|
20
|
+
# Parse hook events
|
|
21
|
+
entries = []
|
|
22
|
+
with open(hook_file) as f:
|
|
23
|
+
for line in f:
|
|
24
|
+
line = line.strip()
|
|
25
|
+
if not line:
|
|
26
|
+
continue
|
|
27
|
+
data = json.loads(line)
|
|
28
|
+
entries.append(data)
|
|
29
|
+
|
|
30
|
+
# Pair Pre/Post by tool_use_id
|
|
31
|
+
pairs = {}
|
|
32
|
+
for entry in entries:
|
|
33
|
+
ts = parse_ts(entry["ts"])
|
|
34
|
+
p = entry["payload"]
|
|
35
|
+
event = p["hook_event_name"]
|
|
36
|
+
tid = p.get("tool_use_id")
|
|
37
|
+
|
|
38
|
+
if event == "Notification":
|
|
39
|
+
ntype = p.get("notification_type", "unknown")
|
|
40
|
+
msg = p.get("message", "")
|
|
41
|
+
print(f" NOTIFICATION: type={ntype} msg={msg[:60]}")
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
if not tid:
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
if event == "PreToolUse":
|
|
48
|
+
cmd = p.get("tool_input", {}).get("command", "")
|
|
49
|
+
tool = p.get("tool_name", "")
|
|
50
|
+
pairs[tid] = {"pre_ts": ts, "cmd": cmd, "tool": tool}
|
|
51
|
+
elif event == "PostToolUse" and tid in pairs:
|
|
52
|
+
pairs[tid]["post_ts"] = ts
|
|
53
|
+
|
|
54
|
+
# Prompted commands from results.md (PROMPTED_ALLOWED)
|
|
55
|
+
prompted_cmds = [
|
|
56
|
+
"for f in a.txt b.txt c.txt; do echo $f; done",
|
|
57
|
+
"for f in *.txt; do cat $f; done",
|
|
58
|
+
"echo $(whoami)",
|
|
59
|
+
"echo $(ls /tmp)",
|
|
60
|
+
"echo {1..5}",
|
|
61
|
+
"echo file-{a,b,c}.txt",
|
|
62
|
+
"cat <(echo hello)",
|
|
63
|
+
"{ echo a; echo b; }",
|
|
64
|
+
"echo $HOME",
|
|
65
|
+
"echo $PATH",
|
|
66
|
+
"echo $MY_CUSTOM_VAR",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
print()
|
|
70
|
+
print(f"{'#':<4} {'Command':<52} {'Delta':>7} {'Sidecar':<18}")
|
|
71
|
+
print("-" * 90)
|
|
72
|
+
|
|
73
|
+
i = 0
|
|
74
|
+
for tid, p in pairs.items():
|
|
75
|
+
if "post_ts" not in p:
|
|
76
|
+
continue
|
|
77
|
+
if p["tool"] != "Bash":
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
i += 1
|
|
81
|
+
cmd = p["cmd"]
|
|
82
|
+
delta = (p["post_ts"] - p["pre_ts"]).total_seconds()
|
|
83
|
+
|
|
84
|
+
was_prompted = cmd in prompted_cmds
|
|
85
|
+
sidecar = "PROMPTED" if was_prompted else "silent"
|
|
86
|
+
|
|
87
|
+
# Truncate command for display
|
|
88
|
+
cmd_display = cmd[:50] + "..." if len(cmd) > 50 else cmd
|
|
89
|
+
|
|
90
|
+
print(f"{i:<4} {cmd_display:<52} {delta:>6.2f}s {sidecar:<18}")
|
|
91
|
+
|
|
92
|
+
# Summary stats
|
|
93
|
+
prompted_deltas = []
|
|
94
|
+
silent_deltas = []
|
|
95
|
+
for tid, p in pairs.items():
|
|
96
|
+
if "post_ts" not in p or p["tool"] != "Bash":
|
|
97
|
+
continue
|
|
98
|
+
delta = (p["post_ts"] - p["pre_ts"]).total_seconds()
|
|
99
|
+
if p["cmd"] in prompted_cmds:
|
|
100
|
+
prompted_deltas.append(delta)
|
|
101
|
+
else:
|
|
102
|
+
silent_deltas.append(delta)
|
|
103
|
+
|
|
104
|
+
print()
|
|
105
|
+
print("Summary:")
|
|
106
|
+
if silent_deltas:
|
|
107
|
+
print(f" Silent commands: avg={sum(silent_deltas)/len(silent_deltas):.2f}s "
|
|
108
|
+
f"min={min(silent_deltas):.2f}s max={max(silent_deltas):.2f}s n={len(silent_deltas)}")
|
|
109
|
+
if prompted_deltas:
|
|
110
|
+
print(f" Prompted commands: avg={sum(prompted_deltas)/len(prompted_deltas):.2f}s "
|
|
111
|
+
f"min={min(prompted_deltas):.2f}s max={max(prompted_deltas):.2f}s n={len(prompted_deltas)}")
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
main()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreToolUse hook: block any Bash command matching a regex pattern.
|
|
3
|
+
#
|
|
4
|
+
# Environment:
|
|
5
|
+
# HOOK_BLOCK_PATTERN - regex matched against tool_input.command (required)
|
|
6
|
+
# PETRIDISH_HOOK_LOG_FILE - JSONL path to log every invocation (optional)
|
|
7
|
+
#
|
|
8
|
+
# Reads the PreToolUse JSON payload on stdin. If the command matches
|
|
9
|
+
# HOOK_BLOCK_PATTERN, emits a JSON decision denying the call with
|
|
10
|
+
# exit 0 (documented mechanism). Otherwise exits 0 silently.
|
|
11
|
+
|
|
12
|
+
set -u
|
|
13
|
+
|
|
14
|
+
LOG_FILE="${PETRIDISH_HOOK_LOG_FILE:-/tmp/petri-hooks.jsonl}"
|
|
15
|
+
PATTERN="${HOOK_BLOCK_PATTERN:-}"
|
|
16
|
+
|
|
17
|
+
PAYLOAD=$(cat)
|
|
18
|
+
|
|
19
|
+
# Log every invocation with timestamp
|
|
20
|
+
TS=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
21
|
+
printf '{"ts":"%s","payload":%s}\n' "$TS" "$PAYLOAD" >> "$LOG_FILE"
|
|
22
|
+
|
|
23
|
+
# No pattern configured: let everything through
|
|
24
|
+
if [ -z "$PATTERN" ]; then
|
|
25
|
+
exit 0
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# Only evaluate Bash tool calls
|
|
29
|
+
TOOL_NAME=$(printf '%s' "$PAYLOAD" | python3 -c "import json,sys; print(json.load(sys.stdin).get('tool_name',''))")
|
|
30
|
+
if [ "$TOOL_NAME" != "Bash" ]; then
|
|
31
|
+
exit 0
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
COMMAND=$(printf '%s' "$PAYLOAD" | python3 -c "import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))")
|
|
35
|
+
|
|
36
|
+
if printf '%s' "$COMMAND" | grep -qE "$PATTERN"; then
|
|
37
|
+
cat <<JSON
|
|
38
|
+
{
|
|
39
|
+
"hookSpecificOutput": {
|
|
40
|
+
"hookEventName": "PreToolUse",
|
|
41
|
+
"permissionDecision": "deny",
|
|
42
|
+
"permissionDecisionReason": "Blocked by policy hook: command matches HOOK_BLOCK_PATTERN"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
JSON
|
|
46
|
+
exit 0
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
exit 0
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Hook logger: reads JSON from stdin, adds timestamp, appends to JSONL file.
|
|
3
|
+
# Usage: Configured as a Claude Code hook command.
|
|
4
|
+
#
|
|
5
|
+
# Environment:
|
|
6
|
+
# PETRIDISH_HOOK_LOG_FILE - path to append JSONL entries (default: /tmp/petri-hooks.jsonl)
|
|
7
|
+
|
|
8
|
+
LOG_FILE="${PETRIDISH_HOOK_LOG_FILE:-/tmp/petri-hooks.jsonl}"
|
|
9
|
+
TS=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
10
|
+
|
|
11
|
+
# Read stdin (the hook JSON payload)
|
|
12
|
+
PAYLOAD=$(cat)
|
|
13
|
+
|
|
14
|
+
# Wrap with timestamp and write as one JSONL line
|
|
15
|
+
printf '{"ts":"%s","payload":%s}\n' "$TS" "$PAYLOAD" >> "$LOG_FILE"
|
|
16
|
+
|
|
17
|
+
# Exit 0: no decision, let normal flow continue
|
|
18
|
+
exit 0
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# inspect-session.sh — mine a Claude Code session JSONL for LSP-relevant content.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# scripts/inspect-session.sh <path-to-session.jsonl>
|
|
6
|
+
#
|
|
7
|
+
# The session JSONL lives at:
|
|
8
|
+
# ~/.local/share/cenv/<env>/projects/<cwd-slug>/<session-id>.jsonl
|
|
9
|
+
#
|
|
10
|
+
# Reports: attachment types, user-message content matching diagnostic patterns,
|
|
11
|
+
# and LSP tool invocations (operation + position).
|
|
12
|
+
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
SESSION="${1:?usage: inspect-session.sh <session.jsonl>}"
|
|
16
|
+
|
|
17
|
+
if [ ! -f "$SESSION" ]; then
|
|
18
|
+
echo "error: session file not found: $SESSION" >&2
|
|
19
|
+
exit 1
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
echo "=== Session: $SESSION ==="
|
|
23
|
+
echo
|
|
24
|
+
|
|
25
|
+
echo "=== Entry type counts ==="
|
|
26
|
+
jq -r '.type' "$SESSION" | sort | uniq -c
|
|
27
|
+
echo
|
|
28
|
+
|
|
29
|
+
echo "=== Attachment types ==="
|
|
30
|
+
jq -r 'select(.type == "attachment") | .attachment.type // (.attachment | keys | join(","))' "$SESSION" \
|
|
31
|
+
| sort | uniq -c
|
|
32
|
+
echo
|
|
33
|
+
|
|
34
|
+
echo "=== User-role content matching diagnostic/lsp patterns ==="
|
|
35
|
+
jq -r 'select(.type == "user") | .message.content | if type == "array" then map(.text // (.content // "") | tostring) | join(" | ") else . end' "$SESSION" \
|
|
36
|
+
| grep -iE 'diagnost|lsp |offense|warning:|error:' \
|
|
37
|
+
| head -20 || echo "(no matches)"
|
|
38
|
+
echo
|
|
39
|
+
|
|
40
|
+
echo "=== LSP tool invocations (assistant tool_use entries) ==="
|
|
41
|
+
jq -r 'select(.type == "assistant") | .message.content | if type == "array" then map(select(.type == "tool_use" and .name == "LSP")) else [] end | .[] | "\(.input.operation // "?") @ \(.input.filePath // "?"):\(.input.line // "?"):\(.input.character // "?")"' "$SESSION" \
|
|
42
|
+
|| echo "(no LSP tool calls)"
|
|
43
|
+
echo
|
|
44
|
+
|
|
45
|
+
echo "=== Any 'lsp_diagnostics' or 'lspDiagnostics' anywhere in raw content ==="
|
|
46
|
+
grep -ciE 'lsp_diagnostics|lspDiagnostics' "$SESSION" || true
|
|
47
|
+
echo
|
|
48
|
+
|
|
49
|
+
echo "=== Tool counts (PreToolUse equivalent — assistant tool_use by name) ==="
|
|
50
|
+
jq -r 'select(.type == "assistant") | .message.content | if type == "array" then map(select(.type == "tool_use") | .name) else [] end | .[]' "$SESSION" \
|
|
51
|
+
| sort | uniq -c
|