Skill: shaiss/local-approvals (version 1.0.0)

--- name: local-approvals description: Local approval system for managing agent permissions. Use CLI to approve/deny requests, view history, and manage auto-approved categories. --- # Local Approvals Skill A local approval system that manages agent permissions with auto-approve lists and approval history tracking. ## Quick Start ```bash # List pending requests python C:\Users\Shai\.openclaw\skills\local-approvals\cli.py list # Approve a request python C:\Users\Shai\.openclaw\skills\local-approvals\cli.py approve abc123 # Deny a request python C:\Users\Shai\.openclaw\skills\local-approvals\cli.py deny abc123 # Show approval history python C:\Users\Shai\.openclaw\skills\local-approvals\cli.py history # Reset an agent's categories python C:\Users\Shai\.openclaw\skills\local-approvals\cli.py reset assistant ``` ## Commands ### approve(id) Approve a pending request by ID. ```bash python cli.py approve <request_id> [--learn] [--reviewer <name>] ``` **Options:** - `--learn`: Add the category to the agent's auto-approve list - `--reviewer`: Who is approving (default: "user") **Example:** ```bash python cli.py approve abc123 --learn ``` ### deny(id) Deny a pending request by ID. ```bash python cli.py deny <request_id> [--reviewer <name>] ``` **Options:** - `--reviewer`: Who is denying (default: "user") **Example:** ```bash python cli.py deny abc123 ``` ### list_pending() List all pending requests, optionally filtered by agent. ```bash python cli.py list [--agent <agent_id>] ``` **Options:** - `--agent`: Filter requests by agent ID **Example:** ```bash python cli.py list --agent assistant ``` ### show_history() Show approval history from state.json. ```bash python cli.py history [--limit <number>] ``` **Options:** - `--limit`: Maximum number of entries to show (default: 20) **Example:** ```bash python cli.py history --limit 50 ``` ### reset_categories(agent) Reset an agent's auto-approved categories list. ```bash python cli.py reset <agent_id> ``` **Example:** ```bash python cli.py reset assistant ``` ## Additional Commands ### categories Show auto-approved categories for one or all agents. ```bash python cli.py categories [--agent <agent_id>] ``` **Options:** - `--agent`: Show categories for specific agent **Example:** ```bash python cli.py categories --agent planner ``` ## State Files The skill maintains two JSON files in the state directory: - **state.json**: Auto-approve lists and approval history - **pending.json**: Pending approval requests Location: `~/.openclaw/skills/local-approvals/` ## Core Functions The `core.py` module provides the underlying functionality: - `check_auto_approve(agent, category)` - Check if a category is auto-approved - `submit_request(agent, category, operation, reasoning)` - Submit a pending request - `learn_category(agent, category)` - Add category to auto-approve list - `get_request(request_id)` - Retrieve a request by ID - `update_request(request_id, decision, reviewer)` - Update request with decision - `list_pending(agent)` - List pending requests - `get_agent_approvals(agent)` - Get agent's auto-approved categories ## Best Practices 1. **Review before approving**: Always check the operation and reasoning before approving 2. **Use auto-learn carefully**: Only use `--learn` for trusted categories that you want to auto-approve 3. **Check history regularly**: Review `history` to understand approval patterns 4. **Reset when needed**: Use `reset` to clear an agent's auto-approve list if you suspect issues ## Examples ### Complete Workflow ```bash # 1. Check what's pending python cli.py list # 2. Review the request details (output shows agent, category, operation, reasoning) # ID: abc123 # Agent: assistant # Category: file_write # Operation: Create config file # Reasoning: Setting up new environment # 3. Approve and auto-learn this category for future python cli.py approve abc123 --learn # 4. Verify it was approved python cli.py list # Should show no pending requests # 5. Check history python cli.py history # 6. View auto-approved categories python cli.py categories ``` ### Managing Categories ```bash # View all auto-approved categories python cli.py categories # View categories for a specific agent python cli.py categories --agent assistant # Reset an agent's categories (clear all auto-approvals) python cli.py reset assistant ``` ## Integration The CLI is designed to be used both interactively and programmatically. Exit codes: - `0`: Success - `1`: Error (request not found, agent not found, etc.) ## Files - `cli.py` - Command-line interface (this file) - `core.py` - Core approval functions - `schemas/` - JSON schema definitions - `schemas/state.json` - State schema template - `schemas/pending.json` - Pending requests schema template

path: cli.py
content: #!/usr/bin/env python3
"""
Local Approvals CLI Module

Command-line interface for managing local agent approval workflows.
Provides commands for approving/denying requests, viewing history, and managing categories.
"""

import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path

# Set UTF-8 encoding for Windows
if sys.platform == 'win32':
    import io
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')

# Import core functions
from core import (
    STATE_DIR,
    STATE_FILE,
    PENDING_FILE,
    _ensure_state_dir,
    _load_state,
    _save_state,
    _load_pending,
    _save_pending,
    check_auto_approve,
    submit_request,
    learn_category,
    get_request,
    update_request,
    list_pending,
    get_agent_approvals,
)


def approve(request_id: str, reviewer: str = "user", auto_learn: bool = False) -> bool:
    """
    Approve a pending request.
    
    Args:
        request_id: The ID of the request to approve
        reviewer: Who is approving (default: "user")
        auto_learn: Whether to add category to auto-approve list
    
    Returns:
        True if approved successfully, False if not found
    """
    request = get_request(request_id)
    if request is None:
        print(f"❌ Error: Request '{request_id}' not found")
        return False
    
    if request["status"] != "pending":
        print(f"⚠️  Request '{request_id}' is not pending (status: {request['status']})")
        return False
    
    # Update the request
    updated = update_request(request_id, "approved", reviewer)
    if updated:
        print(f"✅ Approved request '{request_id}'")
        print(f"   Agent: {updated['agent']}")
        print(f"   Category: {updated['category']}")
        print(f"   Operation: {updated['operation']}")
        
        # Auto-learn the category if requested
        if auto_learn:
            learn_category(updated['agent'], updated['category'])
            print(f"   📚 Category '{updated['category']}' added to auto-approve for agent '{updated['agent']}'")
        
        return True
    
    return False


def deny(request_id: str, reviewer: str = "user") -> bool:
    """
    Deny a pending request.
    
    Args:
        request_id: The ID of the request to deny
        reviewer: Who is denying (default: "user")
    
    Returns:
        True if denied successfully, False if not found
    """
    request = get_request(request_id)
    if request is None:
        print(f"❌ Error: Request '{request_id}' not found")
        return False
    
    if request["status"] != "pending":
        print(f"⚠️  Request '{request_id}' is not pending (status: {request['status']})")
        return False
    
    # Update the request
    updated = update_request(request_id, "denied", reviewer)
    if updated:
        print(f"❌ Denied request '{request_id}'")
        print(f"   Agent: {updated['agent']}")
        print(f"   Category: {updated['category']}")
        print(f"   Operation: {updated['operation']}")
        return True
    
    return False


def list_pending_cmd(agent: str = None) -> None:
    """
    List all pending requests, optionally filtered by agent.
    
    Args:
        agent: If provided, only show requests from this agent
    """
    pending = list_pending(agent)
    
    if not pending:
        if agent:
            print(f"✓ No pending requests for agent '{agent}'")
        else:
            print("✓ No pending requests")
        return
    
    print(f"\n📋 Pending Requests ({len(pending)}):")
    print("-" * 60)
    
    for req_id, req in sorted(pending.items(), key=lambda x: x[1].get('submitted_at', '')):
        print(f"\nID: {req_id}")
        print(f"  Agent:     {req['agent']}")
        print(f"  Category:  {req['category']}")
        print(f"  Operation: {req['operation']}")
        print(f"  Reasoning: {req['reasoning']}")
        print(f"  Submitted: {req['submitted_at']}")
    
    print("-" * 60)


def show_history(limit: int = 20) -> None:
    """
    Show approval history from state.json.
    
    Args:
        limit: Maximum number of history entries to show
    """
    state = _load_state()
    
    # Check if history exists in the expected location
    # The core module may have a different structure, so we need to handle both
    history = state.get("history", [])
    
    if not history:
        print("✓ No approval history found")
        return
    
    # Show the most recent entries (up to limit)
    recent_history = history[-limit:] if len(history) > limit else history
    
    print(f"\n📜 Approval History (showing {len(recent_history)} of {len(history)}):")
    print("-" * 60)
    
    for entry in reversed(recent_history):
        timestamp = entry.get("timestamp", "Unknown time")
        agent = entry.get("agent", "Unknown agent")
        action = entry.get("action", "Unknown action")
        decision = entry.get("decision", "Unknown decision")
        reason = entry.get("reason", "")
        
        status_emoji = "✅" if decision == "approved" else "❌"
        print(f"\n{status_emoji} {timestamp}")
        print(f"   Agent:     {agent}")
        print(f"   Action:    {action}")
        print(f"   Decision:  {decision}")
        if reason:
            print(f"   Reason:    {reason}")
    
    print("-" * 60)


def reset_categories(agent: str) -> bool:
    """
    Reset an agent's auto-approved categories list.
    
    Args:
        agent: The agent ID to reset
    
    Returns:
        True if reset successfully, False if agent not found
    """
    state = _load_state()
    
    # Check for both possible structures
    auto_approve = state.get("auto_approve", {})
    
    if agent not in auto_approve:
        print(f"⚠️  Agent '{agent}' has no auto-approved categories to reset")
        return False
    
    # Clear the categories for this agent
    categories = auto_approve[agent]
    del auto_approve[agent]
    state["auto_approve"] = auto_approve
    
    _save_state(state)
    
    print(f"🔄 Reset auto-approved categories for agent '{agent}'")
    print(f"   Removed categories: {', '.join(categories)}")
    return True


def show_categories(agent: str = None) -> None:
    """
    Show auto-approved categories for one or all agents.
    
    Args:
        agent: If provided, only show categories for this agent
    """
    state = _load_state()
    auto_approve = state.get("auto_approve", {})
    
    if not auto_approve:
        print("✓ No auto-approved categories configured")
        return
    
    if agent:
        if agent not in auto_approve:
            print(f"⚠️  Agent '{agent}' has no auto-approved categories")
            return
        agents_to_show = {agent: auto_approve[agent]}
    else:
        agents_to_show = auto_approve
    
    print("\n📚 Auto-Approved Categories:")
    print("-" * 60)
    
    for agent_id, categories in agents_to_show.items():
        if categories:
            print(f"\n{agent_id}:")
            for cat in categories:
                print(f"  ✓ {cat}")
        else:
            print(f"\n{agent_id}: (none)")
    
    print("-" * 60)


def main():
    """Main entry point for the CLI."""
    parser = argparse.ArgumentParser(
        description="Local Approvals CLI - Manage agent approval requests and categories",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  %(prog)s approve abc123              # Approve request abc123
  %(prog)s approve abc123 --learn      # Approve and auto-learn the category
  %(prog)s deny abc123                 # Deny request abc123
  %(prog)s list                        # List all pending requests
  %(prog)s list --agent assistant      # List pending requests from assistant
  %(prog)s history                     # Show approval history
  %(prog)s history --limit 50          # Show last 50 history entries
  %(prog)s categories                  # Show all auto-approved categories
  %(prog)s categories --agent planner  # Show categories for planner agent
  %(prog)s reset assistant            # Reset assistant's categories
        """
    )
    
    subparsers = parser.add_subparsers(dest="command", help="Available commands")
    
    # Approve command
    approve_parser = subparsers.add_parser("approve", help="Approve a pending request")
    approve_parser.add_argument("id", help="Request ID to approve")
    approve_parser.add_argument("--learn", action="store_true", 
                               help="Add the category to agent's auto-approve list")
    approve_parser.add_argument("--reviewer", default="user",
                               help="Who is approving (default: user)")
    
    # Deny command
    deny_parser = subparsers.add_parser("deny", help="Deny a pending request")
    deny_parser.add_argument("id", help="Request ID to deny")
    deny_parser.add_argument("--reviewer", default="user",
                            help="Who is denying (default: user)")
    
    # List command
    list_parser = subparsers.add_parser("list", help="List pending requests")
    list_parser.add_argument("--agent", help="Filter by agent ID")
    
    # History command
    history_parser = subparsers.add_parser("history", help="Show approval history")
    history_parser.add_argument("--limit", type=int, default=20,
                               help="Maximum number of entries to show (default: 20)")
    
    # Categories command
    cat_parser = subparsers.add_parser("categories", help="Show auto-approved categories")
    cat_parser.add_argument("--agent", help="Show categories for specific agent")
    
    # Reset command
    reset_parser = subparsers.add_parser("reset", help="Reset an agent's auto-approved categories")
    reset_parser.add_argument("agent", help="Agent ID to reset")
    
    # Parse arguments
    args = parser.parse_args()
    
    # Execute the appropriate command
    if args.command == "approve":
        success = approve(args.id, args.reviewer, args.learn)
        sys.exit(0 if success else 1)
    
    elif args.command == "deny":
        success = deny(args.id, args.reviewer)
        sys.exit(0 if success else 1)
    
    elif args.command == "list":
        list_pending_cmd(args.agent)
    
    elif args.command == "history":
        show_history(args.limit)
    
    elif args.command == "categories":
        show_categories(args.agent)
    
    elif args.command == "reset":
        success = reset_categories(args.agent)
        sys.exit(0 if success else 1)
    
    else:
        parser.print_help()
        sys.exit(1)


if __name__ == "__main__":
    main()


path: core.py
content: """
Local Approvals Core Module

Core functions for managing local agent approval workflows.
Handles auto-approve lists, pending requests, and learning from decisions.
"""

import json
import os
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, Optional


# Paths for state and pending files
STATE_DIR = Path(os.path.expanduser("~/.openclaw/skills/local-approvals"))
STATE_FILE = STATE_DIR / "state.json"
PENDING_FILE = STATE_DIR / "pending.json"


def _ensure_state_dir() -> None:
    """Ensure the state directory exists."""
    STATE_DIR.mkdir(parents=True, exist_ok=True)


def _load_state() -> Dict[str, Any]:
    """Load the state.json file, creating it if it doesn't exist."""
    _ensure_state_dir()
    if not STATE_FILE.exists():
        default_state = {
            "auto_approve": {},  # agent -> [categories]
            "last_updated": None
        }
        _save_state(default_state)
        return default_state
    
    with open(STATE_FILE, 'r') as f:
        return json.load(f)


def _save_state(state: Dict[str, Any]) -> None:
    """Save state to state.json."""
    _ensure_state_dir()
    state["last_updated"] = datetime.now().isoformat()
    with open(STATE_FILE, 'w') as f:
        json.dump(state, f, indent=2)


def _load_pending() -> Dict[str, Any]:
    """Load the pending.json file, creating it if it doesn't exist."""
    _ensure_state_dir()
    if not PENDING_FILE.exists():
        pending = {
            "requests": {}  # request_id -> request details
        }
        with open(PENDING_FILE, 'w') as f:
            json.dump(pending, f, indent=2)
        return pending
    
    with open(PENDING_FILE, 'r') as f:
        return json.load(f)


def _save_pending(pending: Dict[str, Any]) -> None:
    """Save pending requests to pending.json."""
    _ensure_state_dir()
    pending["last_updated"] = datetime.now().isoformat()
    with open(PENDING_FILE, 'w') as f:
        json.dump(pending, f, indent=2)


def check_auto_approve(agent: str, category: str) -> bool:
    """
    Check if a category is in an agent's auto-approve list.
    
    Args:
        agent: The agent identifier (e.g., 'assistant', 'planner', etc.)
        category: The category to check (e.g., 'file_write', 'network', etc.)
    
    Returns:
        True if the category is auto-approved for this agent, False otherwise.
    """
    state = _load_state()
    
    # Get the auto-approve list for this agent
    agent_approvals = state.get("auto_approve", {}).get(agent, [])
    
    # Check if category is in the list
    return category in agent_approvals


def submit_request(agent: str, category: str, operation: str, reasoning: str) -> str:
    """
    Submit a pending approval request.
    
    Args:
        agent: The agent requesting approval
        category: The category of the operation
        operation: The operation being requested (e.g., 'write file', 'network request')
        reasoning: The reasoning or justification for the operation
    
    Returns:
        The unique request ID for tracking
    """
    pending = _load_pending()
    
    # Generate a unique request ID
    request_id = str(uuid.uuid4())[:8]  # Short UUID for readability
    
    # Create the request entry
    request = {
        "id": request_id,
        "agent": agent,
        "category": category,
        "operation": operation,
        "reasoning": reasoning,
        "status": "pending",
        "submitted_at": datetime.now().isoformat(),
        "updated_at": None,
        "decision": None,
        "reviewer": None
    }
    
    # Store the request
    pending["requests"][request_id] = request
    _save_pending(pending)
    
    return request_id


def learn_category(agent: str, category: str) -> bool:
    """
    Add a category to an agent's auto-approve list.
    
    This enables learning from approved decisions - once a category is
    trusted for an agent, it gets auto-approved in the future.
    
    Args:
        agent: The agent identifier
        category: The category to add to auto-approve
    
    Returns:
        True if category was added, False if already present
    """
    state = _load_state()
    
    # Initialize auto_approve dict if needed
    if "auto_approve" not in state:
        state["auto_approve"] = {}
    
    # Initialize agent's list if needed
    if agent not in state["auto_approve"]:
        state["auto_approve"][agent] = []
    
    # Add category if not already present
    if category not in state["auto_approve"][agent]:
        state["auto_approve"][agent].append(category)
        _save_state(state)
        return True
    
    return False


def get_request(request_id: str) -> Optional[Dict[str, Any]]:
    """
    Retrieve a pending request by ID.
    
    Args:
        request_id: The request ID to look up
    
    Returns:
        The request details, or None if not found
    """
    pending = _load_pending()
    return pending["requests"].get(request_id)


def update_request(request_id: str, decision: str, reviewer: str) -> Optional[Dict[str, Any]]:
    """
    Update a request with a decision.
    
    Args:
        request_id: The request ID to update
        decision: The decision ('approved' or 'denied')
        reviewer: Who made the decision
    
    Returns:
        The updated request, or None if not found
    """
    pending = _load_pending()
    
    if request_id not in pending["requests"]:
        return None
    
    request = pending["requests"][request_id]
    request["status"] = "decided"
    request["decision"] = decision
    request["reviewer"] = reviewer
    request["updated_at"] = datetime.now().isoformat()
    
    _save_pending(pending)
    return request


def list_pending(agent: Optional[str] = None) -> Dict[str, Dict[str, Any]]:
    """
    List all pending requests, optionally filtered by agent.
    
    Args:
        agent: If provided, only show requests from this agent
    
    Returns:
        Dictionary of request_id -> request details
    """
    pending = _load_pending()
    
    if agent is None:
        return {k: v for k, v in pending["requests"].items() if v["status"] == "pending"}
    else:
        return {k: v for k, v in pending["requests"].items() 
                if v["status"] == "pending" and v["agent"] == agent}


def get_agent_approvals(agent: str) -> list:
    """
    Get the list of auto-approved categories for an agent.
    
    Args:
        agent: The agent identifier
    
    Returns:
        List of auto-approved categories
    """
    state = _load_state()
    return state.get("auto_approve", {}).get(agent, []).copy()

path: pending.json
content: {
  "requests": {
    "29df8ecb": {
      "id": "29df8ecb",
      "agent": "assistant",
      "category": "file_write",
      "operation": "Create test file",
      "reasoning": "Testing the CLI workflow",
      "status": "decided",
      "submitted_at": "2026-02-05T08:41:32.415450",
      "updated_at": "2026-02-05T08:41:40.245946",
      "decision": "approved",
      "reviewer": "user"
    },
    "8cf93479": {
      "id": "8cf93479",
      "agent": "planner",
      "category": "network",
      "operation": "Make API request",
      "reasoning": "Testing denial workflow",
      "status": "decided",
      "submitted_at": "2026-02-05T08:42:03.291670",
      "updated_at": "2026-02-05T08:42:06.783389",
      "decision": "denied",
      "reviewer": "user"
    },
    "23fc6207": {
      "id": "23fc6207",
      "agent": "planner",
      "category": "task",
      "operation": "Create task",
      "reasoning": "Testing agent filter",
      "status": "decided",
      "submitted_at": "2026-02-05T08:42:37.653265",
      "updated_at": "2026-02-05T08:42:45.262222",
      "decision": "approved",
      "reviewer": "user"
    }
  },
  "last_updated": "2026-02-05T08:42:45.263235"
}

path: schemas/pending.json
content: {
  // Schema for pending approval requests in the local-approvals skill
  // This file tracks all requests awaiting user approval

  "requests": [
    // Each request object represents a pending approval
    // id: Unique identifier for this request (UUID format)
    //      Example: "550e8400-e29b-41d4-a716-446655440000"
    //
    // agent: The agent or skill that originated the request
    //        Example: "assistant", "calendar", "email"
    //
    // timestamp: ISO 8601 datetime when the request was created
    //            Example: "2026-02-05T08:34:00.000Z"
    //
    // category: High-level classification of the request type
    //           Examples: "email", "calendar", "task", "system", "notification"
    //
    // operation: The specific action being requested
    //            Examples: "send_email", "create_event", "delete_task", "install_plugin"
    //
    // reasoning: Human-readable explanation of why this request needs approval
    //            Provides context for the user's decision
    //            Example: "Attempting to send email to new recipient outside whitelist"
    //
    // status: Current state of the request
    //         Values: "pending", "approved", "rejected", "expired"
    //
    // expiresAt: ISO 8601 datetime when the request automatically expires if not acted upon
    //            Prevents indefinite queue buildup
    //            Example: "2026-02-05T09:34:00.000Z"

    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "agent": "assistant",
      "timestamp": "2026-02-05T08:34:00.000Z",
      "category": "email",
      "operation": "send_email",
      "reasoning": "Attempting to send email to new recipient outside whitelist",
      "status": "pending",
      "expiresAt": "2026-02-05T09:34:00.000Z"
    }
  ]
}

path: schemas/state.json
content: {
  // Categories organized by agent ID
  // Each agent can have its own set of approval categories
  // Format: { "agent-id": ["category1", "category2", ...] }
  "categories": {},

  // Complete history of all approval decisions
  // Each entry should include: timestamp, agent, action, decision (approved/denied), reason, and context
  // This provides an audit trail for all approval requests
  // Format: [{ "timestamp": "ISO-8601", "agent": "agent-id", "action": "...", "decision": "approved|denied", "reason": "...", "context": {...} }]
  "history": [],

  // Timestamp of the last cleanup operation
  // Used to track when old history entries were pruned or when maintenance was performed
  // Format: ISO-8601 timestamp string (e.g., "2026-02-05T08:34:00.000Z")
  "lastCleanup": null
}


path: state.json
content: {
  "auto_approve": {},
  "last_updated": "2026-02-05T08:42:30.125188"
}

path: _meta.json
content: {
  "name": "local-approvals",
  "version": "1.0.0",
  "description": "Local OSS human-in-the-loop approval system with category learning and multi-channel notifications. Agents request approval for uncategorized operations, auto-learn categories on approval, and get notified via available channels.",
  "author": "OpenClaw",
  "tags": ["approval", "human-in-the-loop", "safety", "guardrails"],
  "requires": {
    "python": ">=3.8",
    "bins": ["python"]
  },
  "homepage": "https://github.com/openclaw/openclaw",
  "repository": "https://github.com/openclaw/openclaw"
}

Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)