Initial commit: custom OpenClaw skills from docker-test

- workspace: capmetro-monitor, github-notifications, model-selector
- workspace-security: vt-monitor, monitor-unauthorized
- workspace-home: cron-manager, monitor-unauthorized
- extensions: vt-sentinel (VT-Sentinel plugin)

Includes sync.sh for pull/push, README, AGENTS.md, .gitignore.
This commit is contained in:
2026-02-16 15:32:44 +00:00
commit 3b7d6bb67c
37 changed files with 3358 additions and 0 deletions

View File

@@ -0,0 +1,209 @@
#!/bin/bash
# Incremental unauthorized connection checker
# Reads the dedicated unauthorized-connections.log (populated by log-splitter.sh),
# compares against seen-connections.json and authorized-ips.json,
# and outputs structured JSON categorizing IPs as new or returning.
#
# Usage: bash check.sh
#
# Output JSON:
# {
# "timestamp": "2026-02-16T12:00:00Z",
# "new_ips": [ { "ip": "...", "first_seen": "...", "reason": "...", ... } ],
# "returning_ips": [ { "ip": "...", "new_attempts": 3, "latest": "...", ... } ],
# "total_events": 12,
# "whitelisted_skipped": 2
# }
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
UNAUTH_LOG="/tmp/openclaw/unauthorized-connections.log"
STATE_DIR="/home/node/.openclaw/workspace-security/memory"
OFFSET_FILE="$STATE_DIR/unauth-check-offset"
SEEN_FILE="$SKILL_DIR/seen-connections.json"
AUTH_FILE="$SKILL_DIR/authorized-ips.json"
mkdir -p "$STATE_DIR"
# Initialize state files if missing
[ ! -s "$SEEN_FILE" ] && echo '{}' > "$SEEN_FILE"
[ ! -s "$AUTH_FILE" ] && echo '{"whitelist":["127.0.0.1","::1","localhost"]}' > "$AUTH_FILE"
# Check log exists
if [ ! -f "$UNAUTH_LOG" ]; then
echo '{"timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","new_ips":[],"returning_ips":[],"total_events":0,"whitelisted_skipped":0}'
exit 0
fi
FILE_SIZE=$(stat -c%s "$UNAUTH_LOG")
# Read offset
LAST_OFFSET=0
if [ -f "$OFFSET_FILE" ]; then
LAST_OFFSET=$(cat "$OFFSET_FILE")
fi
# Handle log rotation / truncation
if [ "$LAST_OFFSET" -gt "$FILE_SIZE" ]; then
LAST_OFFSET=0
fi
BYTES_NEW=$((FILE_SIZE - LAST_OFFSET))
if [ "$BYTES_NEW" -le 0 ]; then
echo "$FILE_SIZE" > "$OFFSET_FILE"
echo '{"timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","new_ips":[],"returning_ips":[],"total_events":0,"whitelisted_skipped":0}'
exit 0
fi
# Load whitelist
WHITELIST=$(jq -r '.whitelist[]' "$AUTH_FILE" 2>/dev/null | tr '\n' '|' | sed 's/|$//')
is_whitelisted() {
local ip="$1"
[[ "$ip" == "127.0.0.1" || "$ip" == "::1" || "$ip" == "localhost" ]] && return 0
[ -n "$WHITELIST" ] && echo "$ip" | grep -qE "^($WHITELIST)$" && return 0
return 1
}
# Parse new log entries into per-IP aggregated data
TMPFILE=$(mktemp)
trap "rm -f $TMPFILE" EXIT
tail -c +"$((LAST_OFFSET + 1))" "$UNAUTH_LOG" > "$TMPFILE"
TOTAL_EVENTS=0
WHITELISTED=0
declare -A IP_EVENTS # ip -> JSON array of events
declare -A IP_LATEST # ip -> latest timestamp
declare -A IP_COUNT # ip -> count of events in this batch
while IFS= read -r line; do
[ -z "$line" ] && continue
TOTAL_EVENTS=$((TOTAL_EVENTS + 1))
# Extract fields from JSON log line
PARSED=$(echo "$line" | jq -r '
[
.time // "",
(.["1"].forwardedFor // ""),
(.["1"].authReason // .["1"].reason // .["1"].cause // ""),
(.["1"].origin // ""),
(.["1"].userAgent // ""),
((.["2"] // "") | tostring | (capture("remote=(?<r>[0-9.]+)") | .r) // "")
] | @tsv
' 2>/dev/null) || continue
IFS=$'\t' read -r ts ip reason origin ua remote <<< "$PARSED"
[ -z "$ip" ] && continue
if is_whitelisted "$ip"; then
WHITELISTED=$((WHITELISTED + 1))
continue
fi
# Build event JSON
EVENT=$(jq -cn \
--arg ts "$ts" \
--arg ip "$ip" \
--arg reason "$reason" \
--arg origin "$origin" \
--arg ua "$ua" \
--arg remote "$remote" \
'{timestamp:$ts, ip:$ip, reason:$reason, origin:$origin, user_agent:$ua, remote:$remote}')
# Aggregate by IP
if [ -z "${IP_EVENTS[$ip]+x}" ]; then
IP_EVENTS[$ip]="$EVENT"
IP_COUNT[$ip]=1
else
IP_EVENTS[$ip]="${IP_EVENTS[$ip]}"$'\n'"$EVENT"
IP_COUNT[$ip]=$(( ${IP_COUNT[$ip]} + 1 ))
fi
IP_LATEST[$ip]="$ts"
done < "$TMPFILE"
# Categorize IPs as new or returning
NEW_IPS="[]"
RETURNING_IPS="[]"
for ip in "${!IP_EVENTS[@]}"; do
COUNT=${IP_COUNT[$ip]}
LATEST=${IP_LATEST[$ip]}
# Get first event for details
FIRST_EVENT=$(echo "${IP_EVENTS[$ip]}" | head -1)
REASON=$(echo "$FIRST_EVENT" | jq -r '.reason')
ORIGIN=$(echo "$FIRST_EVENT" | jq -r '.origin')
UA=$(echo "$FIRST_EVENT" | jq -r '.user_agent')
REMOTE=$(echo "$FIRST_EVENT" | jq -r '.remote')
# Check if IP was previously seen
if jq -e --arg ip "$ip" 'has($ip)' "$SEEN_FILE" >/dev/null 2>&1; then
# Returning IP — update seen record, add to returning list
PREV_COUNT=$(jq -r --arg ip "$ip" '.[$ip].total_attempts // 0' "$SEEN_FILE")
NEW_TOTAL=$((PREV_COUNT + COUNT))
STMP=$(mktemp)
jq --arg ip "$ip" \
--arg latest "$LATEST" \
--argjson count "$NEW_TOTAL" \
--argjson batch "$COUNT" \
'.[$ip].last_seen = $latest | .[$ip].total_attempts = $count | .[$ip].attempts_this_batch = $batch' \
"$SEEN_FILE" > "$STMP" && mv "$STMP" "$SEEN_FILE"
RETURNING_IPS=$(echo "$RETURNING_IPS" | jq \
--arg ip "$ip" \
--arg latest "$LATEST" \
--argjson new_attempts "$COUNT" \
--argjson total "$NEW_TOTAL" \
--arg reason "$REASON" \
--arg origin "$ORIGIN" \
--arg ua "$UA" \
--arg remote "$REMOTE" \
'. += [{ip:$ip, latest:$latest, new_attempts:$new_attempts, total_attempts:$total, reason:$reason, origin:$origin, user_agent:$ua, remote:$remote}]')
else
# New IP — record it, add to new list
STMP=$(mktemp)
jq --arg ip "$ip" \
--arg ts "$LATEST" \
--arg reason "$REASON" \
--arg origin "$ORIGIN" \
--arg ua "$UA" \
--arg remote "$REMOTE" \
--argjson count "$COUNT" \
'. + {($ip): {first_seen: $ts, last_seen: $ts, reason: $reason, origin: $origin, user_agent: $ua, remote: $remote, total_attempts: $count}}' \
"$SEEN_FILE" > "$STMP" && mv "$STMP" "$SEEN_FILE"
NEW_IPS=$(echo "$NEW_IPS" | jq \
--arg ip "$ip" \
--arg first_seen "$LATEST" \
--argjson attempts "$COUNT" \
--arg reason "$REASON" \
--arg origin "$ORIGIN" \
--arg ua "$UA" \
--arg remote "$REMOTE" \
'. += [{ip:$ip, first_seen:$first_seen, attempts:$attempts, reason:$reason, origin:$origin, user_agent:$ua, remote:$remote}]')
fi
done
# Save new offset
echo "$FILE_SIZE" > "$OFFSET_FILE"
# Output structured result
jq -n \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--argjson new_ips "$NEW_IPS" \
--argjson returning_ips "$RETURNING_IPS" \
--argjson total "$TOTAL_EVENTS" \
--argjson whitelisted "$WHITELISTED" \
'{
timestamp: $ts,
new_ips: $new_ips,
returning_ips: $returning_ips,
total_events: $total,
whitelisted_skipped: $whitelisted
}'

View File

@@ -0,0 +1,129 @@
#!/bin/bash
# Unauthorized connection monitor — cron wrapper
# Orchestrates: log-splitter → check → formatted output with agent instructions
#
# Output is structured for the OpenClaw agent to parse and act on.
# The agent reads SKILL.md for detailed instructions on thread management.
#
# Flow:
# 1. Run log-splitter to extract new unauthorized entries from gateway log
# 2. Check thread index status (does the agent need to refresh it?)
# 3. Run check to categorize IPs as new or returning
# 4. Format output with ACTION markers the agent will parse
#
# Exit: always 0. Empty output = nothing to do (agent replies NO_REPLY)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- Step 1: Split unauthorized entries from gateway log ---
SPLIT_RESULT=$("$SCRIPT_DIR/log-splitter.sh" 2>/dev/null) || true
# --- Step 2: Check thread index ---
INDEX_STATUS=$("$SCRIPT_DIR/index-threads.sh" needs-refresh 2>/dev/null) || INDEX_STATUS="missing"
# --- Step 3: Check for new/returning unauthorized connections ---
CHECK_RESULT=$("$SCRIPT_DIR/check.sh" 2>/dev/null) || CHECK_RESULT='{}'
NEW_COUNT=$(echo "$CHECK_RESULT" | jq '.new_ips | length' 2>/dev/null || echo 0)
RETURNING_COUNT=$(echo "$CHECK_RESULT" | jq '.returning_ips | length' 2>/dev/null || echo 0)
TOTAL=$(echo "$CHECK_RESULT" | jq '.total_events' 2>/dev/null || echo 0)
# Nothing to report
if [ "$NEW_COUNT" -eq 0 ] && [ "$RETURNING_COUNT" -eq 0 ] && [ "$INDEX_STATUS" = "fresh" ]; then
exit 0
fi
OUTPUT=""
# --- Thread index refresh needed ---
if [ "$INDEX_STATUS" != "fresh" ]; then
OUTPUT+="ACTION:INDEX_THREADS
The thread index is ${INDEX_STATUS}. Before processing connections, refresh the thread index.
See SKILL.md section \"Thread Index Management\" for instructions.
---END_ACTION---
"
fi
# --- New IPs: agent should create threads ---
if [ "$NEW_COUNT" -gt 0 ]; then
OUTPUT+="
🚨 UNAUTHORIZED CONNECTIONS — ${NEW_COUNT} NEW IP(s) DETECTED
============================================================
"
# Emit each new IP as an ACTION block
for i in $(seq 0 $((NEW_COUNT - 1))); do
IP=$(echo "$CHECK_RESULT" | jq -r ".new_ips[$i].ip")
FIRST_SEEN=$(echo "$CHECK_RESULT" | jq -r ".new_ips[$i].first_seen")
ATTEMPTS=$(echo "$CHECK_RESULT" | jq -r ".new_ips[$i].attempts")
REASON=$(echo "$CHECK_RESULT" | jq -r ".new_ips[$i].reason")
ORIGIN=$(echo "$CHECK_RESULT" | jq -r ".new_ips[$i].origin")
UA=$(echo "$CHECK_RESULT" | jq -r ".new_ips[$i].user_agent")
REMOTE=$(echo "$CHECK_RESULT" | jq -r ".new_ips[$i].remote")
OUTPUT+="
ACTION:NEW_THREAD
IP:${IP}
---
🚨 **NEW UNAUTHORIZED CONNECTION**
**IP:** \`${IP}\`
**First Seen:** ${FIRST_SEEN}
**Attempts:** ${ATTEMPTS}
**Reason:** ${REASON}
**Origin:** ${ORIGIN}
**User Agent:** ${UA}
**Remote (proxy):** ${REMOTE}
_New IP — thread created by security monitor._
---END_ACTION---
"
done
fi
# --- Returning IPs: agent should update existing threads ---
if [ "$RETURNING_COUNT" -gt 0 ]; then
OUTPUT+="
⚠️ RETURNING CONNECTIONS — ${RETURNING_COUNT} KNOWN IP(s)
==========================================================
"
for i in $(seq 0 $((RETURNING_COUNT - 1))); do
IP=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].ip")
LATEST=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].latest")
NEW_ATTEMPTS=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].new_attempts")
TOTAL_ATTEMPTS=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].total_attempts")
REASON=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].reason")
ORIGIN=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].origin")
UA=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].user_agent")
REMOTE=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].remote")
# Look up existing thread
THREAD_INFO=$("$SCRIPT_DIR/index-threads.sh" lookup "$IP" 2>/dev/null) || THREAD_INFO=""
SESSION_KEY=""
if [ -n "$THREAD_INFO" ]; then
SESSION_KEY=$(echo "$THREAD_INFO" | jq -r '.session_key // empty')
fi
OUTPUT+="
ACTION:UPDATE_THREAD
IP:${IP}
SESSION_KEY:${SESSION_KEY}
---
⚠️ **RETURNING UNAUTHORIZED CONNECTION**
**IP:** \`${IP}\`
**Latest Attempt:** ${LATEST}
**New Attempts (this period):** ${NEW_ATTEMPTS}
**Total Attempts (all time):** ${TOTAL_ATTEMPTS}
**Reason:** ${REASON}
**Origin:** ${ORIGIN}
**User Agent:** ${UA}
**Remote (proxy):** ${REMOTE}
_Recurring access attempt — updated by security monitor._
---END_ACTION---
"
done
fi
echo -e "$OUTPUT"

View File

@@ -0,0 +1,123 @@
#!/bin/bash
# Thread index manager for monitor-unauthorized
#
# Maintains a local JSON index of Discord threads so the cron-wrapper
# can determine whether to create a new thread or update an existing one.
#
# The index is populated BY THE AGENT (not by this script) because only
# the agent has access to OpenClaw session/thread listing tools.
#
# This script provides helper operations:
# bash index-threads.sh lookup <ip> — find thread for an IP (exit 0 = found)
# bash index-threads.sh status — check if index exists and is fresh
# bash index-threads.sh record <ip> <session_key> — add/update an entry
# bash index-threads.sh needs-refresh — exit 0 if index is missing/stale
#
# Index location: STATE_DIR/thread-index.json
# Format:
# {
# "indexed_at": "2026-02-16T12:00:00Z",
# "channel_id": "1471181304782389381",
# "threads": {
# "1.2.3.4": {
# "session_key": "agent:security:discord:channel:1471181304782389381:thread:🚨 1.2.3.4 — unauthorized gateway access",
# "thread_name": "🚨 1.2.3.4 — unauthorized gateway access",
# "first_indexed": "2026-02-10T08:00:00Z",
# "last_updated": "2026-02-16T12:00:00Z",
# "update_count": 3
# }
# }
# }
set -e
STATE_DIR="/home/node/.openclaw/workspace-security/memory"
INDEX_FILE="$STATE_DIR/thread-index.json"
CHANNEL_ID="1471181304782389381"
MAX_AGE=86400 # 24 hours before considered stale
mkdir -p "$STATE_DIR"
# Initialize empty index if missing
init_index() {
if [ ! -f "$INDEX_FILE" ] || [ ! -s "$INDEX_FILE" ]; then
jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg ch "$CHANNEL_ID" \
'{indexed_at: $ts, channel_id: $ch, threads: {}}' > "$INDEX_FILE"
fi
}
# Check if index needs refresh (missing, empty, or stale)
needs_refresh() {
if [ ! -f "$INDEX_FILE" ] || [ ! -s "$INDEX_FILE" ]; then
echo "missing"
return 0
fi
local age=$(( $(date +%s) - $(stat -c%Y "$INDEX_FILE" 2>/dev/null || echo 0) ))
if [ "$age" -gt "$MAX_AGE" ]; then
echo "stale"
return 0
fi
echo "fresh"
return 1
}
# Look up a thread by IP
lookup() {
local ip="$1"
init_index
local result
result=$(jq -e --arg ip "$ip" '.threads[$ip] // empty' "$INDEX_FILE" 2>/dev/null)
if [ -n "$result" ]; then
echo "$result"
return 0
fi
return 1
}
# Record a thread entry for an IP
record() {
local ip="$1"
local session_key="$2"
local thread_name="${3:-🚨 $ip — unauthorized gateway access}"
local now
now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
init_index
local tmp
tmp=$(mktemp)
jq --arg ip "$ip" \
--arg sk "$session_key" \
--arg tn "$thread_name" \
--arg now "$now" \
'
.threads[$ip] = (
(.threads[$ip] // {first_indexed: $now, update_count: 0}) |
.session_key = $sk |
.thread_name = $tn |
.last_updated = $now |
.update_count = (.update_count + 1)
)
' "$INDEX_FILE" > "$tmp" && mv "$tmp" "$INDEX_FILE"
echo '{"ok":true}'
}
# Print index status
status() {
init_index
local count
count=$(jq '.threads | length' "$INDEX_FILE")
local indexed_at
indexed_at=$(jq -r '.indexed_at' "$INDEX_FILE")
local age=$(( $(date +%s) - $(stat -c%Y "$INDEX_FILE" 2>/dev/null || echo 0) ))
echo "{\"entries\":$count,\"indexed_at\":\"$indexed_at\",\"age_seconds\":$age,\"stale\":$([ $age -gt $MAX_AGE ] && echo true || echo false)}"
}
# Dispatch
case "${1:-status}" in
lookup) lookup "$2" ;;
record) record "$2" "$3" "$4" ;;
status) status ;;
needs-refresh) needs_refresh ;;
*) echo "Usage: $0 {lookup|record|status|needs-refresh} [args...]" >&2; exit 1 ;;
esac

View File

@@ -0,0 +1,76 @@
#!/bin/bash
# Log splitter for monitor-unauthorized
# Extracts unauthorized WebSocket connection entries from the gateway log
# into a dedicated log file for efficient incremental processing.
#
# Usage:
# bash log-splitter.sh — extract recent entries (batch mode)
# bash log-splitter.sh --full — re-extract from entire log (rebuild)
#
# Filters for log lines containing:
# - "forwardedFor" AND ("unauthorized" OR "pairing-required")
#
# Output: /tmp/openclaw/unauthorized-connections.log
# Each line is a valid JSON object extracted from the gateway log.
set -e
GATEWAY_LOG="/tmp/openclaw/openclaw.log"
UNAUTH_LOG="/tmp/openclaw/unauthorized-connections.log"
STATE_DIR="/home/node/.openclaw/workspace-security/memory"
OFFSET_FILE="$STATE_DIR/unauth-splitter-offset"
mkdir -p "$STATE_DIR"
touch "$UNAUTH_LOG"
FULL_MODE=false
[ "${1:-}" = "--full" ] && FULL_MODE=true
if [ ! -f "$GATEWAY_LOG" ]; then
echo '{"error":"Gateway log not found","log":"'"$GATEWAY_LOG"'"}' >&2
exit 1
fi
FILE_SIZE=$(stat -c%s "$GATEWAY_LOG")
# Determine where to start reading
LAST_OFFSET=0
if [ -f "$OFFSET_FILE" ] && [ "$FULL_MODE" != "true" ]; then
LAST_OFFSET=$(cat "$OFFSET_FILE")
fi
# If file shrank (log rotation), reset
if [ "$LAST_OFFSET" -gt "$FILE_SIZE" ]; then
LAST_OFFSET=0
fi
BYTES_NEW=$((FILE_SIZE - LAST_OFFSET))
if [ "$BYTES_NEW" -le 0 ]; then
echo "$FILE_SIZE" > "$OFFSET_FILE"
echo '{"new_lines":0,"total_lines":'$(wc -l < "$UNAUTH_LOG" | tr -d ' ')'}'
exit 0
fi
# Extract unauthorized connection lines from new bytes
# Filter: must have forwardedFor AND be unauthorized/pairing-required
NEW_LINES=0
TMPFILE=$(mktemp)
trap "rm -f $TMPFILE" EXIT
tail -c +"$((LAST_OFFSET + 1))" "$GATEWAY_LOG" \
| grep '"forwardedFor"' \
| grep -E '"unauthorized"|"pairing-required"' \
> "$TMPFILE" 2>/dev/null || true
NEW_LINES=$(wc -l < "$TMPFILE" | tr -d ' ')
if [ "$NEW_LINES" -gt 0 ]; then
cat "$TMPFILE" >> "$UNAUTH_LOG"
fi
# Save new offset
echo "$FILE_SIZE" > "$OFFSET_FILE"
TOTAL_LINES=$(wc -l < "$UNAUTH_LOG" | tr -d ' ')
echo "{\"new_lines\":$NEW_LINES,\"total_lines\":$TOTAL_LINES}"