#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" CONFIG_PATH="${POLICY_GUARD_CONFIG:-$ROOT_DIR/config/copilot-policy-guard.config.json}" if ! command -v jq >/dev/null 2>&1; then echo "[copilot-policy-guard] jq is required" >&2 exit 1 fi if ! command -v openclaw >/dev/null 2>&1; then echo "[copilot-policy-guard] openclaw CLI is required" >&2 exit 1 fi expand_tilde() { local p="$1" if [[ "$p" == "~" ]]; then echo "$HOME" elif [[ "$p" == "~/"* ]]; then echo "$HOME/${p#~/}" else echo "$p" fi } now_ms() { python3 - <<'PY' import time print(int(time.time()*1000)) PY } send_notice() { local text="$1" local last_channel="$2" local last_to="$3" if [[ -z "$last_channel" || -z "$last_to" ]]; then echo "[copilot-policy-guard] notice skipped (missing channel/target): $text" >&2 return 0 fi local target="$last_to" if [[ "$target" == *:* ]]; then target="${target#*:}" fi openclaw message send \ --channel "$last_channel" \ --target "$target" \ --message "$text" \ >/dev/null 2>&1 || true } if [[ ! -f "$CONFIG_PATH" ]]; then echo "[copilot-policy-guard] missing config: $CONFIG_PATH" >&2 exit 1 fi enabled="$(jq -r '.enabled // true' "$CONFIG_PATH")" if [[ "$enabled" != "true" ]]; then exit 0 fi auto_fix="$(jq -r '.autoFix // true' "$CONFIG_PATH")" session_key="$(jq -r '.sessionKey // "agent:main:main"' "$CONFIG_PATH")" alert_interval_min="$(jq -r '.minAlertIntervalMinutes // 20' "$CONFIG_PATH")" state_file_raw="$(jq -r '.stateFile // "~/.openclaw/copilot-policy-guard-state.json"' "$CONFIG_PATH")" state_file="$(expand_tilde "$state_file_raw")" required_primary_prefix="$(jq -r '.requiredPrimaryPrefix // "github-copilot/"' "$CONFIG_PATH")" required_fallback_prefix="$(jq -r '.requiredFallbackPrefix // "github-copilot/"' "$CONFIG_PATH")" desired_primary_model="$(jq -r '.desiredPrimaryModel // empty' "$CONFIG_PATH")" enforce_allowlist="$(jq -r '.enforceFallbackAllowlist // false' "$CONFIG_PATH")" state_dir="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}" sessions_file="$state_dir/agents/main/sessions/sessions.json" last_channel="" last_to="" if [[ -r "$sessions_file" ]]; then session_json="$(jq -c --arg key "$session_key" '.[$key] // {}' "$sessions_file" 2>/dev/null || true)" if [[ -n "$session_json" && "$session_json" != "{}" ]]; then last_channel="$(jq -r '.lastChannel // empty' <<<"$session_json")" last_to="$(jq -r '.lastTo // empty' <<<"$session_json")" fi fi declare -a issues=() declare -a fixes=() models_json="$(openclaw models status --json 2>/dev/null || true)" if [[ -z "$models_json" ]]; then issues+=("openclaw models status --json failed") else default_model="$(jq -r '.defaultModel // empty' <<<"$models_json")" if [[ -n "$default_model" && "$default_model" != ${required_primary_prefix}* ]]; then issues+=("primary model is not Copilot: $default_model") if [[ "$auto_fix" == "true" && -n "$desired_primary_model" ]]; then if openclaw models set "$desired_primary_model" >/dev/null 2>&1; then fixes+=("set primary model to $desired_primary_model") else issues+=("failed to set primary model to $desired_primary_model") fi fi fi fallbacks=() while IFS= read -r fb; do [[ -z "$fb" ]] && continue fallbacks+=("$fb") done < <(jq -r '.fallbacks[]? // empty' <<<"$models_json") bad_prefix=0 bad_allowlist=0 for fb in "${fallbacks[@]}"; do if [[ "$fb" != ${required_fallback_prefix}* ]]; then bad_prefix=1 issues+=("fallback is not Copilot: $fb") fi done if [[ "$enforce_allowlist" == "true" ]]; then allowed_fallbacks=() while IFS= read -r af; do [[ -z "$af" ]] && continue allowed_fallbacks+=("$af") done < <(jq -r '.allowedFallbacks[]? // empty' "$CONFIG_PATH") if [[ ${#allowed_fallbacks[@]} -gt 0 ]]; then for fb in "${fallbacks[@]}"; do found=0 for af in "${allowed_fallbacks[@]}"; do if [[ "$fb" == "$af" ]]; then found=1 break fi done if [[ $found -eq 0 ]]; then bad_allowlist=1 issues+=("fallback not in allowlist: $fb") fi done fi fi if [[ "$auto_fix" == "true" && ( $bad_prefix -eq 1 || $bad_allowlist -eq 1 ) ]]; then desired_fallbacks=() if [[ "$enforce_allowlist" == "true" ]]; then while IFS= read -r af; do [[ -n "$af" ]] && desired_fallbacks+=("$af") done < <(jq -r '.allowedFallbacks[]? // empty' "$CONFIG_PATH") fi if [[ ${#desired_fallbacks[@]} -eq 0 ]]; then for fb in "${fallbacks[@]}"; do if [[ "$fb" == ${required_fallback_prefix}* ]]; then desired_fallbacks+=("$fb") fi done fi if openclaw models fallbacks clear >/dev/null 2>&1; then fixes+=("cleared fallback list") for fb in "${desired_fallbacks[@]}"; do if openclaw models fallbacks add "$fb" >/dev/null 2>&1; then fixes+=("added fallback $fb") else issues+=("failed to add fallback $fb") fi done else issues+=("failed to clear fallback list") fi fi fi while IFS='=' read -r provider desired; do [[ -z "$provider" ]] && continue desired_bool="false" if [[ "$desired" == "true" ]]; then desired_bool="true" fi current="$(openclaw config get "providers.${provider}.enabled" 2>/dev/null || true)" current_trim="$(printf '%s' "$current" | tr -d '[:space:]')" if [[ "$current_trim" != "$desired_bool" ]]; then issues+=("provider policy drift: providers.${provider}.enabled expected ${desired_bool}, got ${current_trim:-unset}") if [[ "$auto_fix" == "true" ]]; then if openclaw config set --json "providers.${provider}.enabled" "$desired_bool" >/dev/null 2>&1; then fixes+=("set providers.${provider}.enabled=${desired_bool}") else issues+=("failed to set providers.${provider}.enabled=${desired_bool}") fi fi fi done < <(jq -r '.providerPolicy // {} | to_entries[] | "\(.key)=\(.value)"' "$CONFIG_PATH") if [[ ${#issues[@]} -eq 0 && ${#fixes[@]} -eq 0 ]]; then exit 0 fi mkdir -p "$(dirname "$state_file")" if [[ ! -f "$state_file" ]]; then printf '{}\n' > "$state_file" fi state_json="$(cat "$state_file")" last_alert_at="$(jq -r --arg key "$session_key" '.[$key].lastAlertAt // 0' <<<"$state_json")" if [[ ! "$last_alert_at" =~ ^[0-9]+$ ]]; then last_alert_at=0 fi now="$(now_ms)" interval_ms=$((alert_interval_min * 60 * 1000)) should_alert="false" if (( now - last_alert_at >= interval_ms )); then should_alert="true" fi summary="" if [[ ${#fixes[@]} -gt 0 ]]; then summary="Copilot policy guard auto-fixed: ${fixes[*]}" else summary="Copilot policy drift detected: ${issues[*]}" fi echo "[copilot-policy-guard] $summary" if [[ "$should_alert" == "true" ]]; then send_notice "$summary" "$last_channel" "$last_to" state_json="$(jq --arg key "$session_key" --argjson now "$now" '.[$key].lastAlertAt=$now' <<<"$state_json")" printf '%s\n' "$state_json" > "$state_file" fi