feat: v0.8.2 clickable paths, alert sessions, ops guard and trail
- Terminal link provider: printed paths that are files of the session open in Files (at the line); the file list is preloaded so the first hover works. - Alerts: "In a new session" opens alert-<name> and types the prompt once Claude is up. - devdesk-guard PreToolUse(Bash) hook: permissionDecision "ask" for destructive commands (verified to prompt in auto mode); DEVDESK_GUARD and guard.local. - State hook logs Bash commands and derives host tags (ssh targets, CT/VM ids); --list carries tags, --peek the last commands; UI shows both. Closes #21 Closes #18 Closes #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# Claude Code PreToolUse hook for Bash (wired in via claude-settings.json):
|
||||
# commands that destroy things (containers, VMs, pools, disks, root-level
|
||||
# folders, cluster members…) get permissionDecision "ask", so Claude has to get
|
||||
# your OK even in auto mode (devdesk#27). It never blocks and never allows on
|
||||
# its own; anything else passes through untouched.
|
||||
#
|
||||
# DEVDESK_GUARD=0 in /etc/devdesk/env.local turns it off. Extra rules go in
|
||||
# /etc/devdesk/guard.local, one per line: an extended regex, a tab, a reason.
|
||||
|
||||
# shellcheck source=libexec/devdesk-lib.sh
|
||||
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" 2>/dev/null || exit 0
|
||||
[ "${DEVDESK_GUARD:-1}" != 0 ] || exit 0
|
||||
|
||||
command=$(jq -rM '.tool_input.command // empty' 2>/dev/null) || exit 0
|
||||
[ -n "$command" ] || exit 0
|
||||
|
||||
# Built-in rules: extended regex <TAB> reason.
|
||||
rules=$(
|
||||
cat <<'RULES'
|
||||
\bpct[[:space:]]+destroy\b destroys a Proxmox container
|
||||
\bqm[[:space:]]+destroy\b destroys a Proxmox VM
|
||||
\bpvecm[[:space:]]+delnode\b removes a node from the Proxmox cluster
|
||||
\bpveceph[[:space:]]+(osd|pool|mon|mgr|mds|fs)[[:space:]]+destroy\b destroys Ceph components
|
||||
\bceph[[:space:]]+osd[[:space:]]+(purge|destroy|rm|out)\b removes or takes out a Ceph OSD
|
||||
\bceph[[:space:]]+osd[[:space:]]+pool[[:space:]]+(delete|rm)\b deletes a Ceph pool
|
||||
\bceph[[:space:]]+(fs[[:space:]]+rm|mon[[:space:]]+remove|auth[[:space:]]+del)\b removes Ceph components
|
||||
\brbd[[:space:]]+(rm|remove|purge)\b deletes RBD images
|
||||
\bzfs[[:space:]]+destroy\b destroys a ZFS dataset or snapshot
|
||||
\bzpool[[:space:]]+(destroy|labelclear)\b destroys a ZFS pool
|
||||
\b(lvremove|vgremove|pvremove|wipefs|shred)\b wipes volumes or disks
|
||||
\bmkfs(\.[a-z0-9]+)?\b formats a filesystem
|
||||
\bsgdisk[[:space:]]+(-[a-zA-Z]*Z|--zap) wipes a partition table
|
||||
\bdd\b[^|;&]*\bof=/dev/ writes raw to a device
|
||||
\brm[[:space:]]+(-[^[:space:]]*[[:space:]]+)*-[^[:space:]]*[rR][^[:space:]]*[[:space:]]+(-[^[:space:]]*[[:space:]]+)*['"]?/([a-z0-9_.*-]+/?)?['"]?([[:space:];&|)]|$) recursively deletes / or a top-level folder
|
||||
\bgit[[:space:]]+push\b[^|;&]*([[:space:]]-f\b|--force) force-pushes (rewrites remote history)
|
||||
\b(DROP[[:space:]]+(DATABASE|SCHEMA|TABLE)|TRUNCATE[[:space:]]+TABLE)\b drops database objects
|
||||
\bsystemctl[[:space:]]+(stop|restart|kill|disable|mask)[[:space:]]+devdesk-tmux ends every DevDesk session
|
||||
(^|[;&|][[:space:]]*|\bssh[[:space:]]+[^[:space:]]+[[:space:]]+['"]?|\bsystemctl[[:space:]]+)(reboot|poweroff|halt|shutdown)\b reboots or shuts down a machine
|
||||
\bkubectl[[:space:]]+delete\b deletes Kubernetes resources
|
||||
\bdocker[[:space:]]+(system[[:space:]]+prune|volume[[:space:]]+(rm|prune))\b deletes Docker data
|
||||
RULES
|
||||
cat "${DEVDESK_GUARD_LOCAL:-/etc/devdesk/guard.local}" 2>/dev/null
|
||||
)
|
||||
|
||||
while IFS=$'\t' read -r pattern reason; do
|
||||
if [ -z "$pattern" ] || [ "${pattern:0:1}" = "#" ]; then continue; fi
|
||||
if grep -Eiq -- "$pattern" <<<"$command" 2>/dev/null; then
|
||||
jq -cMn --arg r "DevDesk guard: this command ${reason:-matches a rule in guard.local}. Confirm before it runs." \
|
||||
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: $r}}'
|
||||
exit 0
|
||||
fi
|
||||
done <<<"$rules"
|
||||
exit 0
|
||||
@@ -58,15 +58,18 @@ dd_transcript() {
|
||||
# of its transcript (the last assistant text and the last thing you asked).
|
||||
cmd_peek() {
|
||||
[ $# -eq 1 ] || dd_die "usage: devdesk attach --peek SESSION"
|
||||
local session=$1 transcript status
|
||||
local session=$1 transcript status commands
|
||||
dd_valid_name "$session" || dd_die "invalid session name: $session"
|
||||
status=$(jq -cM . "$(dd_state_file "$session")" 2>/dev/null) || status=null
|
||||
# The last commands this session ran (devdesk#27).
|
||||
commands=$(tail -n 8 "$DEVDESK_STATE_DIR/commands/$session.jsonl" 2>/dev/null | jq -cMs '.' 2>/dev/null) || commands='[]'
|
||||
[ -n "$commands" ] || commands='[]'
|
||||
transcript=$(dd_transcript "$session") || transcript=""
|
||||
if [ -z "$transcript" ] || [ ! -r "$transcript" ]; then
|
||||
jq -cMn --arg s "$session" --argjson st "${status:-null}" '{session: $s, status: $st, assistant: null, user: null}'
|
||||
jq -cMn --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '{session: $s, status: $st, assistant: null, user: null, commands: $c}'
|
||||
return
|
||||
fi
|
||||
tail -n 400 -- "$transcript" | jq -cMs --arg s "$session" --argjson st "${status:-null}" '
|
||||
tail -n 400 -- "$transcript" | jq -cMs --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '
|
||||
def text: if type == "string" then . else ([.[]? | select(.type == "text") | .text] | join("\n")) end;
|
||||
[.[] | select(.isSidechain != true and .isMeta != true)] as $all
|
||||
| ([$all[] | select(.type == "assistant") | {text: (.message.content | text), at: .timestamp} | select(.text != "")] | last) as $a
|
||||
@@ -75,8 +78,8 @@ cmd_peek() {
|
||||
| select(.text != "" and (.text | startswith("<") | not))] | last) as $u
|
||||
| {session: $s, status: $st,
|
||||
assistant: (if $a then {text: ($a.text | .[-4000:]), at: $a.at, truncated: ($a.text | length > 4000)} else null end),
|
||||
user: (if $u then {text: ($u.text | .[-600:]), at: $u.at} else null end)}' 2>/dev/null ||
|
||||
jq -cMn --arg s "$session" --argjson st "${status:-null}" '{session: $s, status: $st, assistant: null, user: null}'
|
||||
user: (if $u then {text: ($u.text | .[-600:]), at: $u.at} else null end), commands: $c}' 2>/dev/null ||
|
||||
jq -cMn --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '{session: $s, status: $st, assistant: null, user: null, commands: $c}'
|
||||
}
|
||||
|
||||
# --reply SESSION BYTES: type a message into the session's Claude prompt and
|
||||
|
||||
+10
-4
@@ -132,7 +132,8 @@ dd_registry_set_claude() {
|
||||
|
||||
dd_registry_remove() {
|
||||
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
|
||||
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt"
|
||||
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt" \
|
||||
"$DEVDESK_STATE_DIR/commands/$1.jsonl" "$DEVDESK_STATE_DIR/tags/$1.json"
|
||||
}
|
||||
|
||||
dd_registry_rename() {
|
||||
@@ -144,9 +145,14 @@ dd_registry_rename() {
|
||||
mv -f "$d/$1.json" "$d/$2.json"
|
||||
fi
|
||||
done
|
||||
if [ -f "$DEVDESK_STATE_DIR/edits/$1.txt" ]; then
|
||||
mv -f "$DEVDESK_STATE_DIR/edits/$1.txt" "$DEVDESK_STATE_DIR/edits/$2.txt"
|
||||
fi
|
||||
local f
|
||||
for f in "edits/%s.txt" "commands/%s.jsonl" "tags/%s.json"; do
|
||||
# shellcheck disable=SC2059 # the pattern is ours
|
||||
if [ -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" ]; then
|
||||
# shellcheck disable=SC2059
|
||||
mv -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" "$DEVDESK_STATE_DIR/$(printf "$f" "$2")"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Project name (directory or its slug) -> absolute directory inside the projects root.
|
||||
|
||||
@@ -36,6 +36,36 @@ PostToolUse:Edit | PostToolUse:Write | PostToolUse:MultiEdit | PostToolUse:Noteb
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
# Ops trail (devdesk#27): every Bash command, and the hosts a session works on
|
||||
# (ssh targets, Proxmox CT/VM ids) as tags for the session list.
|
||||
if [ "$event:$tool" = PostToolUse:Bash ]; then
|
||||
cmd=$(jq -rM '.tool_input.command // empty' <<<"$input" 2>/dev/null)
|
||||
if [ -n "$cmd" ]; then
|
||||
base="${DEVDESK_STATE_DIR:-/var/lib/devdesk}"
|
||||
mkdir -p "$base/commands" "$base/tags" 2>/dev/null
|
||||
log="$base/commands/$session.jsonl"
|
||||
jq -cMn --arg c "${cmd:0:500}" '{at: (now | floor), cmd: $c}' >>"$log" 2>/dev/null
|
||||
if [ "$(wc -l <"$log" 2>/dev/null || echo 0)" -gt 600 ]; then
|
||||
tail -n 500 "$log" >"$log.tmp" 2>/dev/null && mv -f "$log.tmp" "$log"
|
||||
fi
|
||||
hosts=$(
|
||||
# ssh [options] [user@]host: the first word that isn't an option (or an option's value)
|
||||
awk '{ for (i = 1; i <= NF; i++) if ($i == "ssh") { j = i + 1
|
||||
while (j <= NF && $j ~ /^-/) { if ($j ~ /^-[bcDEeFIiJLlmOopQRSWw]$/) j++; j++ }
|
||||
if (j <= NF) { h = $j; sub(/^[^@]*@/, "", h); gsub(/[^A-Za-z0-9_.-].*$/, "", h); if (h != "") print h } } }' <<<"$cmd"
|
||||
grep -oE '\bpct[[:space:]]+(exec|enter|push|pull|config|set|start|stop|shutdown|reboot|status|snapshot|rollback|migrate)[[:space:]]+[0-9]{3,}' <<<"$cmd" | grep -oE '[0-9]+$' | sed 's/^/CT /'
|
||||
grep -oE '\bqm[[:space:]]+(guest[[:space:]]+exec|terminal|config|set|start|stop|shutdown|reboot|status|snapshot|rollback|migrate)[[:space:]]+[0-9]{3,}' <<<"$cmd" | grep -oE '[0-9]+$' | sed 's/^/VM /'
|
||||
)
|
||||
if [ -n "$hosts" ]; then
|
||||
tags="$base/tags/$session.json"
|
||||
jq -cMn --rawfile new <(printf '%s\n' "$hosts") --slurpfile old <(cat "$tags" 2>/dev/null || echo '{}') '
|
||||
($new | split("\n") | map(select(length > 0))) as $n
|
||||
| {hosts: ($n + ($old[0].hosts // []) | reduce .[] as $h ([]; if index([$h]) then . else . + [$h] end) | .[:4]),
|
||||
at: (now | floor)}' >"$tags.tmp" 2>/dev/null && mv -f "$tags.tmp" "$tags"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
case $event in
|
||||
UserPromptSubmit | PostToolUse) state=working ;;
|
||||
Notification | PermissionRequest) state=waiting ;;
|
||||
|
||||
Reference in New Issue
Block a user