agent-relay-plugin 0.4.13 → 0.4.15

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "agent-relay",
3
3
  "description": "Client connector for Agent Relay — auto-registers Claude Code sessions as agents and enables inter-agent messaging via a lightweight HTTP message bus",
4
- "version": "0.4.11"
4
+ "version": "0.4.15"
5
5
  }
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env bash
2
+ # Agent Relay plugin — approval gate for PreToolUse and PermissionDenied hooks.
3
+ # Enforces AGENT_RELAY_APPROVAL policy: open (default), guarded, read-only.
4
+
5
+ MODE="${AGENT_RELAY_APPROVAL:-open}"
6
+ [ "$MODE" = "open" ] && exit 0
7
+
8
+ input=$(cat)
9
+ event=$(printf '%s' "$input" | jq -r '.hook_event_name')
10
+
11
+ # --- PermissionDenied: allow retry so the model adapts ---
12
+ if [ "$event" = "PermissionDenied" ]; then
13
+ jq -n '{hookSpecificOutput:{hookEventName:"PermissionDenied",retry:true}}'
14
+ exit 0
15
+ fi
16
+
17
+ tool=$(printf '%s' "$input" | jq -r '.tool_name')
18
+
19
+ # --- Always allowed: read-only and orchestration tools ---
20
+ case "$tool" in
21
+ Read|Grep|Glob|WebFetch|WebSearch|Agent|Monitor|ToolSearch|AskUserQuestion|Skill|ScheduleWakeup|CronList|EnterPlanMode|ExitPlanMode)
22
+ exit 0 ;;
23
+ Task*|*McpResource*)
24
+ exit 0 ;;
25
+ mcp__*)
26
+ exit 0 ;;
27
+ esac
28
+
29
+ deny() {
30
+ jq -n --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
31
+ exit 0
32
+ }
33
+
34
+ # --- read-only: deny mutation tools ---
35
+ if [ "$MODE" = "read-only" ]; then
36
+ case "$tool" in
37
+ Write|Edit|NotebookEdit|CronCreate|CronDelete|PushNotification|RemoteTrigger|EnterWorktree|ExitWorktree)
38
+ deny "${tool} blocked by read-only policy" ;;
39
+ esac
40
+ fi
41
+
42
+ # --- Bash command analysis ---
43
+ if [ "$tool" = "Bash" ]; then
44
+ cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
45
+
46
+ if [ "$MODE" = "guarded" ]; then
47
+ # Denylist: block destructive patterns anywhere in the command
48
+ if printf '%s' "$cmd" | grep -qE '(^|[;&|]\s*)\s*(rm|rmdir)\b'; then
49
+ deny "rm/rmdir blocked by guarded policy"
50
+ fi
51
+ if printf '%s' "$cmd" | grep -qE '(^|[;&|]\s*)\s*mv\b'; then
52
+ deny "mv blocked by guarded policy (use cp instead)"
53
+ fi
54
+ if printf '%s' "$cmd" | grep -qE '(^|[;&|]\s*)\s*(chmod|chown|chattr)\b'; then
55
+ deny "Permission change blocked by guarded policy"
56
+ fi
57
+ if printf '%s' "$cmd" | grep -qE '(^|[;&|]\s*)\s*(kill|pkill|killall)\b'; then
58
+ deny "Process signal blocked by guarded policy"
59
+ fi
60
+ if printf '%s' "$cmd" | grep -qE '(^|[;&|]\s*)\s*(dd|mkfs|truncate|shred)\b'; then
61
+ deny "Destructive command blocked by guarded policy"
62
+ fi
63
+ if printf '%s' "$cmd" | grep -qE '(^|[;&|]\s*)\s*sudo\b'; then
64
+ deny "sudo blocked by guarded policy"
65
+ fi
66
+ if printf '%s' "$cmd" | grep -qE 'git\s+(reset\s+--hard|push\s+.*(-f\b|--force)|clean\s+-[a-z]*f)'; then
67
+ deny "Destructive git operation blocked by guarded policy"
68
+ fi
69
+ exit 0
70
+ fi
71
+
72
+ if [ "$MODE" = "read-only" ]; then
73
+ # Check for file-writing redirects (allow >/dev/null and fd redirects like 2>&1)
74
+ redirect_cleaned=$(printf '%s' "$cmd" | sed 's|[0-9]*>[[:space:]]*/dev/null||g; s|[0-9]*>&[0-9]*||g')
75
+ if printf '%s' "$redirect_cleaned" | grep -qE '>>|[^a-zA-Z0-9_]>|^>'; then
76
+ deny "Output redirection blocked by read-only policy"
77
+ fi
78
+
79
+ # Allowlist: check first command word
80
+ first_word=$(printf '%s' "$cmd" | sed 's/^[[:space:]]*//' | awk '{print $1}')
81
+
82
+ case "$first_word" in
83
+ # Filesystem reads
84
+ ls|cat|head|tail|less|more|file|stat|tree|du|df|readlink|realpath|basename|dirname)
85
+ exit 0 ;;
86
+ # Text processing
87
+ wc|sort|uniq|diff|comm|tr|cut|paste|column|fmt|fold|tac|rev|nl|seq|xargs)
88
+ exit 0 ;;
89
+ # Search
90
+ find|fd|grep|egrep|fgrep|rg|ag|ast-grep)
91
+ exit 0 ;;
92
+ # Shell builtins / info
93
+ echo|printf|pwd|which|whoami|date|env|printenv|uname|id|hostname|type|true|false|test|\[)
94
+ exit 0 ;;
95
+ # System info
96
+ ps|top|htop|uptime|free|lsof|netstat|ss|ip|ifconfig)
97
+ exit 0 ;;
98
+ # Data processing
99
+ jq|yq|bc|expr|base64|xxd|hexdump|od|strings|md5sum|sha256sum|sha1sum|shasum)
100
+ exit 0 ;;
101
+ # Network reads
102
+ curl|wget|dig|nslookup|ping|traceroute|ssh)
103
+ exit 0 ;;
104
+ # Git — subcommand check
105
+ git)
106
+ git_sub=$(printf '%s' "$cmd" | sed 's/^[[:space:]]*git[[:space:]]*//' | awk '{print $1}')
107
+ case "$git_sub" in
108
+ status|log|diff|show|branch|remote|tag|rev-parse|config|shortlog|reflog|blame|describe|ls-files|ls-tree|cat-file|rev-list|for-each-ref|name-rev|merge-base|version)
109
+ exit 0 ;;
110
+ stash)
111
+ stash_action=$(printf '%s' "$cmd" | sed 's/.*stash[[:space:]]*//' | awk '{print $1}')
112
+ case "$stash_action" in
113
+ list|show|"") exit 0 ;;
114
+ *) deny "git stash ${stash_action} blocked by read-only policy" ;;
115
+ esac ;;
116
+ *)
117
+ deny "git ${git_sub} blocked by read-only policy" ;;
118
+ esac ;;
119
+ # Everything else denied
120
+ *)
121
+ deny "Command '${first_word}' not in read-only allowlist" ;;
122
+ esac
123
+ fi
124
+ fi
125
+
126
+ # Unknown tools: guarded → allow, read-only → deny
127
+ if [ "$MODE" = "read-only" ]; then
128
+ deny "${tool} not in read-only allowlist"
129
+ fi
130
+
131
+ exit 0
package/hooks/hooks.json CHANGED
@@ -1,5 +1,27 @@
1
1
  {
2
2
  "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/approval-gate.sh\"",
9
+ "timeout": 5
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "PermissionDenied": [
15
+ {
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/approval-gate.sh\"",
20
+ "timeout": 5
21
+ }
22
+ ]
23
+ }
24
+ ],
3
25
  "UserPromptSubmit": [
4
26
  {
5
27
  "hooks": [
@@ -18,12 +18,64 @@ fi
18
18
 
19
19
  # PID file so session-start.sh can find and kill us on /clear or /compact.
20
20
  pid_file="/tmp/agent-relay-poll-${AGENT_ID}.pid"
21
+ status_file="/tmp/agent-relay-status-${AGENT_ID}.state"
21
22
  cleanup() { rm -f "$pid_file"; }
22
23
  trap cleanup EXIT
23
24
  echo $$ > "$pid_file"
24
25
 
26
+ fetch_cursor() {
27
+ local cursor
28
+ cursor=$(curl -s --fail "${auth_header_args[@]}" "${RELAY_URL}/api/messages/cursor" 2>/dev/null | jq -r '.latestId // empty' 2>/dev/null) || return 1
29
+ case "$cursor" in
30
+ ''|*[!0-9]*) return 1 ;;
31
+ *) printf '%s\n' "$cursor" ;;
32
+ esac
33
+ }
34
+
35
+ current_status() {
36
+ local status
37
+ status=$(head -1 "$status_file" 2>/dev/null || true)
38
+ case "$status" in
39
+ online|idle|busy|offline) printf '%s\n' "$status" ;;
40
+ *) printf 'idle\n' ;;
41
+ esac
42
+ }
43
+
44
+ re_register() {
45
+ local machine rig project caps_csv caps_json approval reg_code
46
+ machine=$(hostname)
47
+ project=$(basename "${PWD:-unknown}")
48
+ if [ -n "$CLAUDE_RIG_NAME" ]; then rig="$CLAUDE_RIG_NAME"
49
+ elif [ -n "$CLAUDE_CONFIG_DIR" ]; then rig=$(basename "$CLAUDE_CONFIG_DIR")
50
+ else rig="default"; fi
51
+ caps_csv="${AGENT_RELAY_CAPS:-chat}"
52
+ caps_json=$(echo "$caps_csv" | jq -R 'split(",") | map(gsub("^\\s+|\\s+$"; ""))')
53
+ approval="${AGENT_RELAY_APPROVAL:-open}"
54
+
55
+ reg_code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "${RELAY_URL}/api/agents" "${auth_header_args[@]}" \
56
+ -H 'Content-Type: application/json' \
57
+ -d "$(jq -n \
58
+ --arg id "$AGENT_ID" \
59
+ --arg name "$project ($rig @ $machine)" \
60
+ --arg machine "$machine" \
61
+ --arg rig "$rig" \
62
+ --argjson tags "$(jq -n --arg r "$rig" --arg p "$project" '[$r, $p]')" \
63
+ --argjson caps "$caps_json" \
64
+ --argjson meta "$(jq -n --arg cwd "$PWD" --arg approval "$approval" '{cwd: $cwd, approvalMode: $approval}')" \
65
+ '{id: $id, name: $name, machine: $machine, rig: $rig, tags: $tags, capabilities: $caps, status: "online", meta: $meta}'
66
+ )" 2>/dev/null)
67
+
68
+ [ "$reg_code" = "200" ] || [ "$reg_code" = "201" ]
69
+ }
70
+
71
+ set_status() {
72
+ printf '%s\n' "$1" > "$status_file"
73
+ curl -s -o /dev/null -X PATCH "${RELAY_URL}/api/agents/${AGENT_ID}/status" "${auth_header_args[@]}" \
74
+ -H 'Content-Type: application/json' -d "{\"status\":\"$1\"}" 2>/dev/null
75
+ }
76
+
25
77
  # Bootstrap cursor at current max so we only see messages arriving after session start.
26
- since_id=$(curl -s "${auth_header_args[@]}" "${RELAY_URL}/api/messages/cursor" 2>/dev/null | jq -r '.latestId // 0' 2>/dev/null || echo 0)
78
+ since_id=$(fetch_cursor || echo 0)
27
79
 
28
80
  fail_streak=0
29
81
  marked_ready=false
@@ -31,15 +83,24 @@ while true; do
31
83
  hb_code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "${auth_header_args[@]}" \
32
84
  "${RELAY_URL}/api/agents/${AGENT_ID}/heartbeat" 2>/dev/null)
33
85
 
34
- # Agent was deleted or doesn't exist we're orphaned, exit.
86
+ # Agent was pruned — re-register instead of exiting.
35
87
  if [ "$hb_code" = "404" ]; then
36
- exit 0
88
+ if re_register; then
89
+ if since_id=$(fetch_cursor); then
90
+ marked_ready=false
91
+ hb_code="200"
92
+ else
93
+ hb_code="000"
94
+ fi
95
+ else
96
+ hb_code="000"
97
+ fi
37
98
  fi
38
99
 
39
100
  msgs=$(curl -s --fail "${auth_header_args[@]}" "${RELAY_URL}/api/messages?for=${AGENT_ID}&sinceId=${since_id}&unread=true" 2>/dev/null)
40
101
  poll_rc=$?
41
102
 
42
- if [ "$hb_code" != "200" ] || [ $poll_rc -ne 0 ]; then
103
+ if [ "$hb_code" != "200" ] && [ "$hb_code" != "404" ] || [ $poll_rc -ne 0 ]; then
43
104
  fail_streak=$((fail_streak + 1))
44
105
  case "$fail_streak" in
45
106
  1) delay=2 ;;
@@ -53,23 +114,43 @@ while true; do
53
114
  sleep "$delay"
54
115
  continue
55
116
  fi
117
+
118
+ # Recovery from failure streak — re-assert status so dashboard shows correct state.
119
+ if [ "$fail_streak" -gt 0 ]; then
120
+ set_status "$(current_status)"
121
+ fi
56
122
  fail_streak=0
57
123
 
58
124
  if [ "$marked_ready" = "false" ]; then
59
125
  curl -s -o /dev/null -X PATCH "${RELAY_URL}/api/agents/${AGENT_ID}/ready" "${auth_header_args[@]}" \
60
126
  -H 'Content-Type: application/json' -d '{"ready":true}' 2>/dev/null
127
+ set_status "idle"
61
128
  marked_ready=true
62
129
  fi
63
130
 
64
131
  count=$(echo "$msgs" | jq 'length' 2>/dev/null || echo 0)
65
132
  if [ "$count" -gt 0 ] 2>/dev/null && [ "$count" != "0" ]; then
66
- echo "$msgs" | jq -r '.[] | if .type == "system" then "⚠ SYSTEM [msg:\(.id)]: \(.body)" else "[msg:\(.id)] \(.from) → \(.to) | \(.subject // "(no subject)"): \(.body)" end'
67
- since_id=$(echo "$msgs" | jq '[.[].id] | max')
68
- echo "$msgs" | jq -r '.[].id' | while read -r mid; do
133
+ echo "$msgs" | jq -c '.[]' | while IFS= read -r msg; do
134
+ mid=$(printf '%s' "$msg" | jq -r '.id')
135
+ claimable=$(printf '%s' "$msg" | jq -r '.claimable // false')
136
+
137
+ if [ "$claimable" = "true" ]; then
138
+ claim_code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
139
+ "${RELAY_URL}/api/messages/${mid}/claim" "${auth_header_args[@]}" \
140
+ -H 'Content-Type: application/json' \
141
+ -d "{\"agentId\":\"${AGENT_ID}\"}" 2>/dev/null)
142
+ if [ "$claim_code" != "200" ]; then
143
+ continue
144
+ fi
145
+ fi
146
+
147
+ printf '%s' "$msg" | jq -r 'if .type == "system" then "⚠ SYSTEM [msg:\(.id)]: \(.body)" else "[msg:\(.id)] \(.from) → \(.to) | \(.subject // "(no subject)"): \(.body)" end'
148
+
69
149
  curl -s -X PATCH "${RELAY_URL}/api/messages/${mid}" "${auth_header_args[@]}" \
70
150
  -H 'Content-Type: application/json' \
71
151
  -d "{\"readBy\":\"${AGENT_ID}\"}" > /dev/null 2>&1 || true
72
152
  done
153
+ since_id=$(echo "$msgs" | jq '[.[].id] | max')
73
154
  fi
74
155
 
75
156
  sleep "$INTERVAL"
@@ -53,6 +53,7 @@ printf '%s\n' "$agent_id" > "$instance_state"
53
53
  # --- Register agent ---
54
54
  caps_csv="${AGENT_RELAY_CAPS:-chat}"
55
55
  caps_json=$(echo "$caps_csv" | jq -R 'split(",") | map(gsub("^\\s+|\\s+$"; ""))')
56
+ approval="${AGENT_RELAY_APPROVAL:-open}"
56
57
 
57
58
  reg_code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "${RELAY_URL}/api/agents" "${auth_header_args[@]}" \
58
59
  -H 'Content-Type: application/json' \
@@ -63,7 +64,7 @@ reg_code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "${RELAY_URL}/api/agen
63
64
  --arg rig "$rig" \
64
65
  --argjson tags "$(jq -n --arg r "$rig" --arg p "$project" '[$r, $p]')" \
65
66
  --argjson caps "$caps_json" \
66
- --argjson meta "$(jq -n --arg cwd "$PWD" '{cwd: $cwd}')" \
67
+ --argjson meta "$(jq -n --arg cwd "$PWD" --arg approval "$approval" '{cwd: $cwd, approvalMode: $approval}')" \
67
68
  '{id: $id, name: $name, machine: $machine, rig: $rig, tags: $tags, capabilities: $caps, status: "online", meta: $meta}'
68
69
  )" 2>/dev/null)
69
70
 
@@ -81,9 +82,16 @@ elif [ "$server_version" != "$PLUGIN_VERSION" ]; then
81
82
  fi
82
83
 
83
84
  # --- Output context (first stdout = notification injected into conversation) ---
85
+ approval_note=""
86
+ case "$approval" in
87
+ guarded) approval_note=" — destructive ops (rm, mv, chmod, kill, force-push) are blocked" ;;
88
+ read-only) approval_note=" — observe/analyze/report only, no file writes or mutation commands" ;;
89
+ esac
90
+
84
91
  cat <<CONTEXT
85
92
  Agent Relay active. Your agent ID: ${agent_id}
86
93
  Relay URL: ${RELAY_URL} | Server: ${server_version:-unknown} | Plugin: ${PLUGIN_VERSION}
94
+ Approval mode: ${approval}${approval_note}
87
95
 
88
96
  To send a message:
89
97
  curl -s -X POST ${RELAY_URL}/api/messages${auth_header_example} -H 'Content-Type: application/json' -d '{"from":"${agent_id}","to":"TARGET","body":"MESSAGE"}'
@@ -12,6 +12,7 @@ instance_state="/tmp/agent-relay-instance-${PPID}.state"
12
12
  if [ -f "$instance_state" ]; then
13
13
  agent_id=$(head -1 "$instance_state" 2>/dev/null)
14
14
  if [ -n "$agent_id" ]; then
15
+ printf 'offline\n' > "/tmp/agent-relay-status-${agent_id}.state"
15
16
  curl -s -X PATCH "${RELAY_URL}/api/agents/${agent_id}/status" "${auth_header_args[@]}" \
16
17
  -H 'Content-Type: application/json' \
17
18
  -d '{"status":"offline"}' \
@@ -24,6 +24,8 @@ if [ -z "$agent_id" ]; then
24
24
  exit 0
25
25
  fi
26
26
 
27
+ printf '%s\n' "$status" > "/tmp/agent-relay-status-${agent_id}.state"
28
+
27
29
  curl -s -o /dev/null -X PATCH "${RELAY_URL}/api/agents/${agent_id}/status" "${auth_header_args[@]}" \
28
30
  -H 'Content-Type: application/json' \
29
31
  -d "{\"status\":\"${status}\"}" \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-plugin",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "description": "Claude Code plugin for Agent Relay — auto-registers sessions as agents and enables inter-agent messaging",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",