feat(skills): add self-config skill

safe config editing with secret redaction

- config.sh redacted|summary
- config-patch.sh start|'... jq patch..'|commit|diff|sort|status|show|rollback|reset|backup
- service.sh restart|restart-auto-rollback|confirm
This commit is contained in:
picoclaw 2026-03-01 23:29:27 +00:00
parent 25f26f305b
commit ac71769b70
4 changed files with 549 additions and 0 deletions

View file

@ -0,0 +1,89 @@
---
name: self-config
description: Allows the agent to safely update its own configuration files
---
# Self-Config Skill Capabilities
1. Presentation of the `config.json` with secrets redacted.
2. Staging of a `config.new.json` with utilities for patching, sorting, diffing, and validation.
3. Safe service restarts with auto-rollback timers.
## Tools used
- `scripts/config.sh`: Base utility for presenting redacted JSON files.
- `scripts/config-patch.sh`: Tools for iterative patching of a staged `config.new.json` file.
- `scripts/service.sh`: Manages service restarts with auto-rollback.
- `jq`: Used internally for JSON manipulation.
## View Current Configuration
### Redacted (Full)
```bash
scripts/config.sh redacted
```
### Summary (Filtered)
Shows only configured models, agents, enabled tools, devices, and heartbeat settings.
```bash
scripts/config.sh summary
```
---
## Workflow: Safe Configuration Update
### 1. Start a Session
Initialize the staging environment. This redacts sensitive keys (tokens, passwords) into a separate hidden file so they aren't exposed in plain text during editing.
```bash
scripts/config-patch.sh start
```
### 2. Apply Patches
Apply `jq` filters to the **staged** file. You can run this multiple times.
```bash
scripts/config-patch.sh '.path.to.key = "new_value"'
```
### 3. Review Changes
Review the diff between the original and the staged (redacted) version.
```bash
scripts/config-patch.sh diff
```
### 4. View Staged Config
```bash
scripts/config-patch.sh config
# OR
scripts/config-patch.sh summary
```
### 5. Sort Keys (Optional)
Sort the keys in the staged file alphabetically.
```bash
scripts/config-patch.sh sort
```
### 6. Reset (Abort)
If you make a mistake *before* switching, clear the staging files.
```bash
scripts/config-patch.sh reset
```
### 7. Switch & Test (The "Hot" Update)
Commit the patch, restart the service, and create a timer that will rollback
unless 'confirm' action is performed. Default is 120 seconds.
```bash
TIMEOUT=300 scripts/service.sh restart-auto-rollback
```
### 8. Confirm
If the agent is still working and the changes are correct, confirm the update to remove the rollback marker.
```bash
scripts/service.sh confirm
```
## Safety Rules
1. **Always** use `start` before applying patches.
2. **Never** manually edit the `.secrets.json` file.
3. **Always** confirm your changes within the time limit after a `switch`.

View file

@ -0,0 +1,162 @@
#!/bin/bash
# config-patch: A tool for iterative JSON patching with high-security secret handling.
# Determine script directory for relocatable operation
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
# Source shared config and functions
source "$SCRIPT_DIR/config.sh"
COMMAND="${1:-help}"
case "$COMMAND" in
start|sort)
[ -f "$SECRETS_FILE" ] && { echo "Error: Session active. Commit or reset first."; exit 1; }
[ ! -f "$PICOCLAW_CONFIG" ] && { echo "Error: $PICOCLAW_CONFIG not found."; exit 1; }
redact_secrets || exit 1
# On success - Move results final locations (trap will clean if script crashes before this)
# Clear <file>_TMP so trap doesn't try to delete final files
mv "$CONFIG_TMP" "$STAGING_FILE"
CONFIG_TMP=""
mv "$SECRETS_TMP" "$SECRETS_FILE"
SECRETS_TMP=""
echo "Start: Redacted staging file created. Secrets mapped to $SECRETS_FILE"
;;&
start)
exit 0
;;
sort)
{ rm "$STAGING_FILE" ; jq -S . > "$STAGING_FILE"; } < "$STAGING_FILE"
echo "Sorted: keys in $STAGING_FILE"
exit 0
;;
commit)
[ ! -f "$STAGING_FILE" ] && { echo "Error: No staged changes."; exit 1; }
[ ! -f "$SECRETS_FILE" ] && { echo "Error: No secrets file found."; exit 1; }
# 3. Restoration & Validation
FINAL_FILE=$(mktemp)
# Restore secrets: replace path placeholders with stored values
# Keys in secrets file are the paths (e.g., "SECRET:agents.defaults.model")
jq --slurpfile secrets "$SECRETS_FILE" 'walk(if type == "string" and $secrets[0][.] != null then $secrets[0][.] else . end)' "$STAGING_FILE" > "$FINAL_FILE"
# Ensure no placeholders survived (means they weren't in the map)
# Check if any string key in secrets map still exists in output
while IFS= read -r key; do
if grep -q "\"$key\"" "$FINAL_FILE"; then
echo "Error: Placeholder '$key' was not restored. Check for typos in secrets map."
exit 1
fi
done < <(jq -r 'keys[]' "$SECRETS_FILE")
# 4. JSON Integrity Check
jq . "$FINAL_FILE" > /dev/null 2>&1 || { echo "Error: Invalid JSON output. Aborting."; exit 1; }
# 5. Backup & Swap (unredacted config)
mkdir -p "$BACKUP_DIR"
cp "$PICOCLAW_CONFIG" "$BACKUP_DIR/${BASE}_${TIMESTAMP}.json"
mv "$FINAL_FILE" "$PICOCLAW_CONFIG"
rm -f "$STAGING_FILE" "$SECRETS_FILE"
echo "Committed successfully. Backup: $BACKUP_DIR/${BASE}_${TIMESTAMP}.json"
exit 0
;;
status)
echo "Target: $PICOCLAW_CONFIG"
[ -f "$STAGING_FILE" ] && echo "Staging: Active ($STAGING_FILE)" || echo "Staging: None"
[ -f "$SECRETS_FILE" ] && echo "Secrets: $(jq 'keys | length' "$SECRETS_FILE") tracked" || echo "Secrets: None"
;;
summary)
# Output a summary of the staged config with only configured models, agents, enabled tools, devices, and heartbeat
summarize "$STAGING_FILE"
exit 0
;;
show|redacted|config)
# Output the full staged config
cat "$STAGING_FILE"
;;
diff)
[ ! -f "$STAGING_FILE" ] && { echo "Error: No staged changes to diff."; exit 1; }
redact_secrets
diff -u "$CONFIG_TMP" "$STAGING_FILE"
exit 0
;;
rollback)
# Restore the most recent backup
LATEST=$(ls -t "$BACKUP_DIR"/${BASE}_*.json 2>/dev/null | head -n 1)
if [ -z "$LATEST" ]; then
echo "Error: No backups found for $PICOCLAW_CONFIG"
exit 1
fi
LATEST_BASE=$(basename "$LATEST" .json)
cp "$LATEST" "$PICOCLAW_CONFIG"
echo "Rollback: Restored $PICOCLAW_CONFIG from $LATEST"
exit 0
;;
reset)
rm -f "$STAGING_FILE" "$SECRETS_FILE"
echo "Session cleared."
exit 0
;;
help)
echo "Usage: $0 <command> [config_file]"
echo "Commands:"
echo " start - Create staging file with redacted secrets"
echo " sort - Sort JSON keys alphabetically"
echo " diff - Show staged changes"
echo " commit - Apply staged changes to config"
echo " reset - Clear staging (discard changes)"
echo " rollback - Restore from last backup"
echo " status - Show current state"
echo " summary - Show summary of the staged config with unused models/chatconfigs filtered"
echo " config - Show the staged config"
echo " <jq expr> - Apply inline jq patch (e.g. '.agents.model=\"gpt-4\"')"
exit 0
;;
*)
# Generic Patching
redact_secrets
if [ -f "$STAGING_FILE" ]; then
SOURCE="$STAGING_FILE"
else
SOURCE="$PICOCLAW_CONFIG"
fi
TMP=$(mktemp)
if jq "$COMMAND" "$SOURCE" > "$TMP"; then
mv "$TMP" "$STAGING_FILE"
echo "Applied patch to $STAGING_FILE"
else
rm -f "$TMP"; exit 1
fi
;;
esac

View file

@ -0,0 +1,157 @@
#!/bin/bash
# config: A tool for presenting config in insecure channels.
# Input validation functions
assert_is_identifier() {
if ! echo "$1" | grep -qE '^[a-zA-Z_][a-zA-Z0-9_-]*$'; then
echo "Error: $2" >&2
return 1
fi
}
assert_is_file_path() {
if [ -z "$1" ]; then
echo "Error: $2" >&2
return 1
fi
local dir
dir=$(dirname "$1")
if [ ! -d "$dir" ] && [ "$dir" != "." ]; then
echo "Error: Directory does not exist: $dir" >&2
return 1
fi
}
assert_is_safe() {
# Only check the command argument, not the entire command line
local cmd="$1"
if echo "$cmd" | grep -qE '[\$\`\\]'; then
echo "Error: $2 - Command contains dangerous characters" >&2
return 1
fi
return 0
}
COMMAND="${1:-help}"
PICOCLAW_CONFIG="${2:-}"
PICOCLAW_CONFIG="${PICOCLAW_CONFIG:-$HOME/.picoclaw/config.json}"
assert_is_safe "$COMMAND" "Command must be a valid identifier" || exit 1
assert_is_file_path "$PICOCLAW_CONFIG" "Config file path must be valid" || exit 1
# Setup paths
BASE=$(basename "$PICOCLAW_CONFIG" .json)
DIR=$(dirname "$PICOCLAW_CONFIG")
STAGING_FILE="$DIR/$BASE.new.json"
SECRETS_FILE="$DIR/.$BASE.secrets.json"
BACKUP_DIR="$DIR/.config_backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# Temp file for staging (cleaned up on script exit)
CONFIG_TMP=""
SECRETS_TMP=""
FINAL_FILE=""
cleanup() { rm -f "$CONFIG_TMP" "$FINAL_FILE" "$SECRETS_TMP"; }
trap cleanup EXIT
SECRET_PATTERN="key|pass|secret|token|auth|credential|sid|sk-"
EXCLUDE_PATTERN="max_tokens|auth_method|enable_auth|auth_type"
# Shared function: extract secrets if not already done
redact_secrets() {
# Check if original has any secrets
paths=$(jq -c "paths(scalars) | select(.[-1] | tostring | ascii_downcase | (test(\"$SECRET_PATTERN\"; \"i\") and (test(\"$EXCLUDE_PATTERN\"; \"i\") | not)))" "$PICOCLAW_CONFIG" 2>/dev/null) || return 0
# Create staging file in temp (global var for trap cleanup)
CONFIG_TMP=$(mktemp)
cp "$PICOCLAW_CONFIG" "$CONFIG_TMP"
# Extract secrets directly to final location
SECRETS_TMP=$(mktemp)
echo "{}" > "$SECRETS_TMP"
chmod 600 "$SECRETS_TMP"
while read -r p; do
[ -z "$p" ] && continue
ID="SECRET:$(echo "$p" | jq -r '. | map(tostring) | map(if . | test("^[0-9]+$") then "[" + . + "]" else "." + . end) | join("") | ltrimstr(".")')"
VAL=$(jq -r "getpath($p)" "$PICOCLAW_CONFIG")
# Add to secrets file
{ rm -f "$SECRETS_TMP" ; jq --arg id "$ID" --arg val "$VAL" '. + {($id): $val}' > "$SECRETS_TMP"; } < "$SECRETS_TMP"
# Replace with placeholder in staging
{ rm -f "$CONFIG_TMP" ; jq --argjson p "$p" --arg id "$ID" 'setpath($p; $id)' > "${CONFIG_TMP}"; } < "$CONFIG_TMP"
done <<< "$paths"
}
# Output a summary with only configured models, agents, enabled tools, devices, and heartbeat
summarize()
{
jq '
def referenced_models:
[.. | objects | if has("model") then .model elif has("model_name") then .model_name else null end // empty] | unique;
def used_model_ids:
referenced_models;
def is_configured:
. != null and . != {} and . != [];
def filter_tools:
(walk(
if type == "object" then
if .enabled == false then
null
else
. as $obj | reduce keys_unsorted[] as $k ({}; . + {($k): ($obj[$k] | filter_tools)})
end
else
.
end
) | if type == "object" then with_entries(select(.value != null)) else . end);
. * {
model_list: (.model_list // []) | map(select(.model_name as $id | used_model_ids | contains([$id]))),
agents: (.agents // {}) | map_values(select(is_configured))
} | .channels = ((.channels // {}) | to_entries | map(select(.value.enabled == true)) | from_entries)
| .tools = ((.tools // {}) | filter_tools)
| .providers = ((.providers // {} | to_entries | map(select(.value.api_key | gsub("^\\s+"; "") | gsub("\\s+$"; "") != "")) | from_entries))
| .devices = (if (.devices.enabled // false) then .devices else null end)
| .heartbeat = (if (.heartbeat.enabled // false) then .heartbeat else null end)
' "${1}" | jq 'with_entries(select(.value != null))'
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
case "$COMMAND" in
redacted)
redact_secrets
cat "$CONFIG_TMP"
;;
summary)
# Output a summary with only configured models, agents, enabled tools, devices, and heartbeat
redact_secrets
summarize "$CONFIG_TMP"
exit 0
;;
help)
echo "Usage: $0 <command> [config_file]"
echo "Commands:"
echo " redacted - Show config (default: PICOCLAW_CONFIG)"
echo " summary - Show config with unused models/chatconfigs filtered"
exit 0
;;
esac
fi

View file

@ -0,0 +1,141 @@
#!/bin/bash
# service.sh - Service management with auto-rollback support
# A "dead man's switch" for configuration changes.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Validate identifier (alphanumeric, dash, underscore)
assert_is_identifier() {
if ! echo "$1" | grep -qE '^[a-zA-Z_][a-zA-Z0-9_-]*$'; then
echo "Error: $2" >&2
return 1
fi
}
assert_is_number ()
{
# Use grep for robust, portable POSIX regex matching.
if ! echo "$1" | grep -qE '^[0-9]+$';
then
echo "Error: $2" >&2
return 1
fi
}
SERVICE_NAME="${PICOCLAW_SERVICE_NAME:-picoclaw}"
COMMAND="${1:-help}"
assert_is_identifier "$COMMAND" "Action must be a valid identifier" || exit 1
SERVICE="${2:-$SERVICE_NAME}"
assert_is_identifier "$SERVICE" "Service name must be a valid identifier" || exit 1
CONFIG="${3:-${PICOCLAW_CONFIG:-$HOME/.picoclaw/config.json}}"
[[ -n "$CONFIG" ]] && ! [[ -f "$CONFIG" ]] && echo "Expected file" && exit 1
case "$COMMAND" in
restart)
echo "Restarting service: $SERVICE"
systemctl --user restart "$SERVICE"
echo "Done."
;;
_rollback)
# Check if marker file exists (auto-rollback was set)
MARKER="${CONFIG}-PENDING-ROLLBACK"
if [ ! -f "$MARKER" ]; then
# No marker = nothing to rollback = success
exit 0
fi
# Marker exists - perform rollback
rm -f "$MARKER"
"$SCRIPT_DIR/config.sh" rollback "$CONFIG"
echo "[ROLLBACK] Restarting service: $SERVICE"
systemctl --user restart "$SERVICE" || true
echo "Rollback complete."
;;
restart-auto-rollback)
TIMEOUT="${TIMEOUT:-}"
TIMEOUT="${4:-120}"
assert_is_number "$TIMEOUT" "Timer must be number of seconds" || exit 1
# Create marker file
MARKER="${CONFIG}-PENDING-ROLLBACK"
# Restart service first
echo "Restarting service: $SERVICE"
systemctl --user restart "$SERVICE"
echo "Auto-rollback armed for $SERVICE (timeout: ${TIMEOUT}s)"
echo "Marker: $MARKER"
# Start background timer
(
sleep "$TIMEOUT"
if [ -f "$MARKER" ]; then
echo "[TIMEOUT] Auto-rollback triggered for $SERVICE"
bash "$0" _rollback "$SERVICE" "$CONFIG"
fi
) &
TIMER_PID=$!
{
echo "Timer PID: $TIMER_PID"
echo "----------------------------------------------------"
echo "Service will ROLLBACK in $TIMEOUT seconds if not confirmed."
echo "To confirm: service.sh confirm $SERVICE"
echo "----------------------------------------------------"
} | tee "$MARKER"
;;
confirm)
MARKER="${CONFIG}-PENDING-ROLLBACK"
if [ -n "$MARKER" ] && [ -f "$MARKER" ]; then
rm -f "$MARKER"
echo "Rollback cancelled. Changes confirmed."
else
echo "No pending rollback to confirm."
fi
;;
help)
echo "Usage: $0 <command> [args...]"
echo ""
echo "Commands:"
echo " restart [service] Restart service (default: \$PICOCLAW_SERVICE_NAME or picoclaw)"
echo " auto-rollback [service] [config] [timeout]"
echo " Arm rollback timer, restart service"
echo " confirm [service] [config] Cancel pending rollback"
echo " _rollback [service] [config]"
echo " Internal: check marker, rollback if present"
echo ""
echo "Environment:"
echo " PICOCLAW_SERVICE_NAME Default service name (default: picoclaw)"
echo " PICOCLAW_CONFIG Default config file (default: picoclaw)"
exit 0
;;
*)
echo "Unknown command: $COMMAND"
echo "Run '$0 help' for usage."
exit 1
;;
esac