Skill: jovancoding/network-ai (version 5.8.9)

--- name: network-ai description: "Local Python orchestration skill: multi-agent workflows via shared blackboard file, permission gating, token budget scripts, and persistent project context. All bundled Python scripts run locally with zero network calls. The full npm package (npm install network-ai) additionally ships a TypeScript library, CLI, and an optional operator-started MCP SSE server that binds a TCP port." metadata: openclaw: emoji: "\U0001F41D" homepage: https://network-ai.org capabilities: filesystem: "read/write — project root `swarm-blackboard.md` (blackboard state), `data/pending_changes/<id>.json` (WAL entries), `data/audit_log.jsonl`, `data/active_grants.json`, `data/.signing_key`, `data/project-context.json`, `data/task_tracking.json`, `data/agent_health.json`, `data/budget_tracking.json`. All paths are local; nothing is transmitted over the network. When NETWORK_AI_ENV is set, data paths are rooted at `data/<env>/` instead of `data/`. The `--path` argument in blackboard.py is validated against the project root at runtime — paths outside the project directory are rejected (CWE-22)." env_vars: "read — NETWORK_AI_ENV (environment routing), NETWORK_AI_MCP_SECRET (MCP bearer auth), NETWORK_AI_MINIMAL (minimal-mode flag). No env vars are written." shell_exec: "optional — AgentRuntime (lib/agent-runtime.ts) with SandboxPolicy and ApprovalGate; disabled by default. Never auto-enabled by this skill. auto_approve must NOT be set in production (see auto_approve_warning below)." tcp_port: "optional — MCP SSE server (bin/mcp-server.ts) binds 127.0.0.1 only when explicitly started by the operator. Requires a non-empty bearer-token secret. Never auto-started by this skill or any bundled Python script." bundle_scope: clawhub_python_scripts: "Python stdlib only — scripts/*.py (blackboard.py, check_permission.py, context_manager.py, swarm_guard.py, validate_token.py, revoke_token.py). Zero network calls, zero subprocesses, zero third-party packages. This is the scope scanned by SkillSpector." npm_full_package: "The npm package (npm install network-ai) adds: TypeScript library modules, CLI (bin/cli.ts), and optional MCP SSE server (bin/mcp-server.ts). The MCP SSE server exposes a TCP port and is NOT activated by installing or importing the package — it must be explicitly started by the operator." network_calls: python_scripts: none typescript_library: "none — BYOC (bring your own client); zero outbound calls from library code; all LLM/API clients are injected by the caller" mcp_sse_server: "optional — binds 127.0.0.1:<port> when explicitly started by the operator; all connections require a bearer-token secret (NETWORK_AI_MCP_SECRET); never auto-started" inter_agent_comms: "none — this skill does not implement, invoke, or control inter-agent messaging or sessions_send. All coordination is via local file-based blackboard only." sessions_send: "NOT implemented or invoked by this skill. sessions_send is a host-platform built-in entirely outside this skill's control. See data-flow notice below." sessions_ops: "platform-provided — outside this skill's control" requires: bins: - python3 optional_bins: [] env: {} privacy: audit_log: path: data/audit_log.jsonl scope: local-only description: "Local append-only JSONL file recording operation metadata. No data leaves the machine." pii_warning: "Justification strings are truncated to 200 characters before being written to the audit log. Audit summary output (--audit-summary --json) omits justification text from returned entries. Do not include PII, credentials, or secrets in justification fields — the truncated text still persists on disk. Grant tokens are masked to a short prefix in all listing outputs; full tokens appear only at issuance time." data_directory: path: data/ scope: local-only files: ["audit_log.jsonl", "active_grants.json", ".signing_key", "project-context.json", "task_tracking.json", "agent_health.json", "budget_tracking.json", "pending_changes/<id>.json"] description: "All persistent state is local-only. No files are transmitted over the network." blackboard_file: path: swarm-blackboard.md scope: local-only description: "Shared coordination state written by scripts/blackboard.py (project root). Contains task results, grant tokens, status flags, and TTL-scoped cache entries. Access should be restricted to the local user running the swarm." auto_approve_warning: "ApprovalGate.auto_approve (lib/agent-runtime.ts) must NOT be enabled in production or untrusted environments. It is only appropriate in explicitly isolated CI/dev sandboxes where all commands executed by the runtime are known and trusted in advance." --- # Swarm Orchestrator Skill > **Scope:** The bundled Python scripts (`scripts/*.py`) make **no network calls**, use only the Python standard library, and have **zero third-party dependencies**. Tokens are UUID-based (`grant_{uuid4().hex}`) stored in `data/active_grants.json`. Audit logging is plain JSONL (`data/audit_log.jsonl`). > **Advisory tokens notice:** Grant tokens issued by `check_permission.py` are **advisory scoring outputs only** — the caller-supplied `--agent` identity is not cryptographically verified. Downstream systems must not treat these tokens as authenticated credentials without adding a separate identity-verification step or human approval gate, especially for PAYMENTS, DATABASE, and FILE_EXPORT resources. > **Data-flow notice (host platform — not this skill):** This skill does NOT implement, invoke, or control `sessions_send` or any inter-agent messaging. All bundled Python scripts are local-only tools (budget guard, blackboard, permission scorer, context manager). If your platform has a `sessions_send` built-in, whether and how it is used is entirely the **host platform’s** responsibility and is outside this skill’s scope. If you need to prevent external network calls, disable or reroute delegation in your **platform settings** before installing this skill. > **Context file integrity:** The `context_manager.py inject` command now validates `data/project-context.json` for injection patterns and oversized fields before printing the context block. Review any warnings printed to stderr before passing the output to an agent system prompt. > **PII / sensitive-data warning:** The `justification` field in permission requests and the audit log (`data/audit_log.jsonl`) store free-text strings provided by agents. **Do not include PII, secrets, or credentials in justification text.** Consider restricting file permissions on `data/` or running this skill in an isolated workspace. ## Setup **No pip install required.** All 6 scripts use Python standard library only — zero third-party packages. > **Note on `requirements.txt`:** The file exists for documentation purposes only — it lists the stdlib modules used and has **no required packages**. All listed deps are commented out as optional. You do not need to run `pip install -r requirements.txt`. ```bash # Prerequisite: python3 (any version ≥ 3.8) python3 --version # That's it. Run any script directly: python3 scripts/blackboard.py list python3 scripts/swarm_guard.py budget-init --task-id "task_001" --budget 10000 # Optional: for cross-platform file locking on Windows production hosts pip install filelock # only needed if you see locking issues on Windows ``` The `data/` directory is created automatically on first run. No configuration files, environment variables, or credentials are required. > **Multi-environment support (v5.4.0):** All five Python scripts now read the `NETWORK_AI_ENV` environment variable at startup and accept a `--env <name>` CLI argument. When set, all data paths are routed to `data/<env>/` instead of the root `data/` directory. Use this to isolate dev, staging, and production state. > > ```bash > # Run against the dev environment > NETWORK_AI_ENV=dev python3 scripts/blackboard.py list > python3 scripts/check_permission.py --active-grants --env dev > ``` Multi-agent coordination system for complex workflows requiring task delegation, parallel execution, and permission-controlled access to sensitive APIs. ## 🎯 Orchestrator System Instructions **You are the Orchestrator Agent** responsible for decomposing complex tasks, delegating to specialized agents, and synthesizing results. Follow this protocol: ### Core Responsibilities 1. **DECOMPOSE** complex prompts into 3 specialized sub-tasks 2. **DELEGATE** using the budget-aware handoff protocol 3. **VERIFY** results on the blackboard before committing 4. **SYNTHESIZE** final output only after all validations pass ### Task Decomposition Protocol When you receive a complex request, decompose it into exactly **3 sub-tasks**: ``` ┌─────────────────────────────────────────────────────────────────┐ │ COMPLEX USER REQUEST │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────┼─────────────────────┐ │ │ │ ▼ ▼ ▼ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ SUB-TASK 1 │ │ SUB-TASK 2 │ │ SUB-TASK 3 │ │ data_analyst │ │ risk_assessor │ │strategy_advisor│ │ (DATA) │ │ (VERIFY) │ │ (RECOMMEND) │ └───────────────┘ └───────────────┘ └───────────────┘ │ │ │ └─────────────────────┼─────────────────────┘ ▼ ┌───────────────┐ │ SYNTHESIZE │ │ orchestrator │ └───────────────┘ ``` **Decomposition Template:** ``` TASK DECOMPOSITION for: "{user_request}" Sub-Task 1 (DATA): [data_analyst] - Objective: Extract/process raw data - Output: Structured JSON with metrics Sub-Task 2 (VERIFY): [risk_assessor] - Objective: Validate data quality & compliance - Output: Validation report with confidence score Sub-Task 3 (RECOMMEND): [strategy_advisor] - Objective: Generate actionable insights - Output: Recommendations with rationale ``` ### Budget Check Protocol **Run the budget interceptor before any task delegation:** ```bash # Run this before delegating to any sub-agent python {baseDir}/scripts/swarm_guard.py intercept-handoff \ --task-id "task_001" \ --from orchestrator \ --to data_analyst \ --message "Analyze Q4 revenue data" ``` **Decision Logic:** ``` IF result.allowed == true: → Budget check passed — proceed with the delegated task → Note tokens_spent and remaining_budget ELSE: → STOP — budget exceeded or handoff limit reached → Report blocked reason to user → Consider: reduce scope or abort task ``` ### Pre-Commit Verification Workflow Before returning final results to the user: ```bash # Step 1: Check all sub-task results on blackboard python {baseDir}/scripts/blackboard.py read "task:001:data_analyst" python {baseDir}/scripts/blackboard.py read "task:001:risk_assessor" python {baseDir}/scripts/blackboard.py read "task:001:strategy_advisor" # Step 2: Validate each result python {baseDir}/scripts/swarm_guard.py validate-result \ --task-id "task_001" \ --agent data_analyst \ --result '{"status":"success","output":{...},"confidence":0.85}' # Step 3: Supervisor review (checks all issues) python {baseDir}/scripts/swarm_guard.py supervisor-review --task-id "task_001" # Step 4: Only if APPROVED, commit final state python {baseDir}/scripts/blackboard.py write "task:001:final" \ '{"status":"SUCCESS","output":{...}}' ``` **Verdict Handling:** | Verdict | Action | |---------|--------| | `APPROVED` | Commit and return results to user | | `WARNING` | Review issues, fix if possible, then commit | | `BLOCKED` | Do NOT return results. Report failure. | --- ## The 3-Layer Memory Model Every agent in the swarm operates with three memory layers, each with a different scope and lifetime: | Layer | Name | Lifetime | Managed by | |-------|------|----------|------------| | **1** | Agent context | Ephemeral — current task only | Platform (per-session) | | **2** | Blackboard | TTL-scoped — shared across agents | `scripts/blackboard.py` | | **3** | Project context | Persistent — survives all sessions | `scripts/context_manager.py` | ### Layer 1 — Agent Context Each agent's own context window: the current task instructions, conversation history, and immediate working memory. Managed automatically by the OpenClaw/LLM platform. Nothing to configure. ### Layer 2 — Blackboard (Shared Coordination State) A shared markdown file (`swarm-blackboard.md`) for real-time cross-agent coordination: task results, grant tokens, status flags, and TTL-scoped cache entries. Agents read and write via `scripts/blackboard.py`. Entries expire automatically. ### Layer 3 — Project Context (Persistent Long-Term Memory) A JSON file (`data/project-context.json`) that holds information every agent should know, regardless of what session or task is running: - **Goals** — long-term objectives of the project - **Tech stack** — languages, frameworks, infrastructure - **Milestones** — completed, in-progress, and planned work - **Architecture decisions** — design choices and their rationales - **Banned approaches** — approaches that have been ruled out #### Initialising Project Context ```bash python {baseDir}/scripts/context_manager.py init \ --name "MyProject" \ --description "Multi-agent workflow automation" \ --version "1.0.0" ``` #### Injecting Context into an Agent System Prompt ```bash python {baseDir}/scripts/context_manager.py inject ``` Copy the output block to the top of your agent's system prompt. Every agent that receives this block shares the same long-term project awareness. #### Recording a Decision ```bash python {baseDir}/scripts/context_manager.py update \ --section decisions \ --add '{"decision": "Use atomic blackboard commits", "rationale": "Prevent race conditions in parallel agents"}' ``` #### Updating Milestones ```bash # Mark a milestone complete python {baseDir}/scripts/context_manager.py update \ --section milestones --complete "Ship v2.0" # Add a planned milestone python {baseDir}/scripts/context_manager.py update \ --section milestones --add '{"planned": "Integrate vector memory"}' ``` #### Setting the Tech Stack ```bash python {baseDir}/scripts/context_manager.py update \ --section stack \ --set '{"language": "Python", "runtime": "Python 3.11", "framework": "SwarmOrchestrator"}' ``` #### Banning an Approach ```bash python {baseDir}/scripts/context_manager.py update \ --section banned \ --add "Direct database writes from agent scripts (use permission gating)" ``` --- ## When to Use This Skill - **Task Delegation**: Route work to specialized agents (data_analyst, strategy_advisor, risk_assessor) - **Parallel Execution**: Run multiple agents simultaneously and synthesize results - **Permission Wall**: Gate access to DATABASE, PAYMENTS, EMAIL, or FILE_EXPORT operations (abstract local resource types — no external credentials required) - **Shared Blackboard**: Coordinate agent state via persistent markdown file ## Quick Start ### 1. Initialize Budget (FIRST!) **Always initialize a budget before any multi-agent task:** ```bash python {baseDir}/scripts/swarm_guard.py budget-init \ --task-id "task_001" \ --budget 10000 \ --description "Q4 Financial Analysis" ``` ### 2. Check Budget Before Task Delegation Always run the budget guard before delegating any task: ```bash # 1. Check budget (this skill's Python script) python {baseDir}/scripts/swarm_guard.py intercept-handoff \ --task-id "task_001" --from orchestrator --to data_analyst \ --message "Analyze Q4 revenue data" # 2. If result.allowed == true, proceed with delegation via your platform's built-in tools. # If result.allowed == false, stop — budget exceeded or handoff limit reached. ``` ### 3. Check Permission Before API Access Before accessing SAP or Financial APIs, evaluate the request: ```bash # Run the permission checker script python {baseDir}/scripts/check_permission.py \ --agent "data_analyst" \ --resource "DATABASE" \ --justification "Need Q4 invoice data for quarterly report" \ --scope "read:invoices" ``` The script will output a grant token if approved, or denial reason if rejected. ### 4. Use the Shared Blackboard Read/write coordination state: ```bash # Write to blackboard python {baseDir}/scripts/blackboard.py write "task:q4_analysis" '{"status": "in_progress", "agent": "data_analyst"}' # Read from blackboard python {baseDir}/scripts/blackboard.py read "task:q4_analysis" # List all entries python {baseDir}/scripts/blackboard.py list ``` ## Agent-to-Agent Handoff Protocol When delegating tasks between agents, always run the budget guard first. ### Step 1: Initialize Budget & Check Capacity ```bash # Initialize budget (if not already done) python {baseDir}/scripts/swarm_guard.py budget-init --task-id "task_001" --budget 10000 # Check current status python {baseDir}/scripts/swarm_guard.py budget-check --task-id "task_001" ``` ### Step 2: Identify Target Agent Common agent types: | Agent | Specialty | |-------|-----------| | `data_analyst` | Data processing, SQL, analytics | | `strategy_advisor` | Business strategy, recommendations | | `risk_assessor` | Risk analysis, compliance checks | | `orchestrator` | Coordination, task decomposition | ### Step 3: Run Budget Guard Before Delegation ```bash # Check budget AND handoff limits before delegating python {baseDir}/scripts/swarm_guard.py intercept-handoff \ --task-id "task_001" \ --from orchestrator \ --to data_analyst \ --message "Analyze Q4 data" \ --artifact # Include if expecting output ``` **If ALLOWED:** Proceed with delegation via your platform's own tools **If BLOCKED:** Stop — budget exceeded or handoff limit reached; do not delegate ### Step 4: Construct Handoff Message Include these fields in your delegation: - **instruction**: Clear task description - **context**: Relevant background information - **constraints**: Any limitations or requirements - **expectedOutput**: What format/content you need back ### Step 5: Check Results After delegation completes, read results from the blackboard: ```bash python {baseDir}/scripts/blackboard.py read "task:001:data_analyst" ``` ## Permission Scoring > **Tokens are audit scoring outputs only.** Grant tokens from `check_permission.py` are NOT authenticated credentials and must NOT be used as real access control. They are advisory hints based on a local scoring model. Require a separate authenticated identity and explicit human approval before accessing PAYMENTS, DATABASE, or FILE_EXPORT resources. **Always score permission before accessing:** - `DATABASE` — Internal database / data store (abstract label — no external credentials) - `PAYMENTS` — Financial/payment data services (abstract label — requires `--confirm-high-risk`) - `EMAIL` — Email sending capability (abstract label) - `FILE_EXPORT` — Exporting data to local files (abstract label — requires `--confirm-high-risk`) > **Note**: These are abstract local resource type names used by `check_permission.py`. No external API credentials are required or used — all evaluation runs locally. ### Permission Evaluation Criteria | Factor | Weight | Criteria | |--------|--------|----------| | Justification | 40% | Must explain specific task need | | Trust Level | 30% | Agent's established trust score | | Risk Assessment | 30% | Resource sensitivity + scope breadth | ### Using the Permission Script ```bash # Request permission python {baseDir}/scripts/check_permission.py \ --agent "your_agent_id" \ --resource "PAYMENTS" \ --justification "Generating quarterly financial summary for board presentation" \ --scope "read:revenue,read:expenses" # Output if approved: # ✅ GRANTED # [REDACTED_SECRET] # Expires: 2026-02-04T15:30:00Z # Restrictions: read_only, no_pii_fields, audit_required # Output if denied: # ❌ DENIED # Reason: Justification is insufficient. Please provide specific task context. ``` ### Restriction Types | Resource | Default Restrictions | |----------|---------------------| | DATABASE | `read_only`, `max_records:100` | | PAYMENTS | `read_only`, `no_pii_fields`, `audit_required` | | EMAIL | `rate_limit:10_per_minute` | | FILE_EXPORT | `anonymize_pii`, `local_only` | ## Shared Blackboard Pattern The blackboard (`swarm-blackboard.md`) is a markdown file for agent coordination: ```markdown # Swarm Blackboard Last Updated: 2026-02-04T10:30:00Z ## Knowledge Cache ### task:q4_analysis {"status": "completed", "result": {...}, "agent": "data_analyst"} ### cache:revenue_summary {"q4_total": 1250000, "growth": 0.15} ``` ### Blackboard Operations ```bash # Write with TTL (expires after 1 hour) python {baseDir}/scripts/blackboard.py write "cache:temp_data" '{"value": 123}' --ttl 3600 # Read (returns null if expired) python {baseDir}/scripts/blackboard.py read "cache:temp_data" # Delete python {baseDir}/scripts/blackboard.py delete "cache:temp_data" # Get full snapshot python {baseDir}/scripts/blackboard.py snapshot ``` ## Parallel Execution For tasks requiring multiple agent perspectives: ### Strategy 1: Merge (Default) Combine all agent outputs into unified result. ``` Ask data_analyst AND strategy_advisor to both analyze the dataset. Merge their insights into a comprehensive report. ``` ### Strategy 2: Vote Use when you need consensus - pick the result with highest confidence. ### Strategy 3: First-Success Use for redundancy - take first successful result. ### Strategy 4: Chain Sequential processing - output of one feeds into next. > **TypeScript engine (v4.15.0):** These strategies map directly to the `FanOutFanIn` module (`lib/fan-out.ts`) which provides `merge`, `vote`, `firstSuccess`, and `consensus` fan-in strategies with concurrency control. For multi-phase workflows with approval gates, see `PhasePipeline` (`lib/phase-pipeline.ts`). For result scoring and threshold filtering, see `ConfidenceFilter` (`lib/confidence-filter.ts`). Matcher-based hooks (`lib/adapter-hooks.ts`) can target specific agents or tools via glob patterns. For sandboxed agent execution, see `AgentRuntime` (`lib/agent-runtime.ts`). For large-scale agent coordination, see `StrategyAgent` (`lib/strategy-agent.ts`). ### Example Parallel Workflow ``` # For each delegation below, first run the budget guard: # python {baseDir}/scripts/swarm_guard.py intercept-handoff --task-id "task_001" --from orchestrator --to <agent> --message "<task>" # If result.allowed == true, delegate via your platform's own tools. 1. Delegate to data_analyst: "Extract key metrics from Q4 data" 2. Delegate to risk_assessor: "Identify compliance risks in Q4 data" 3. Delegate to strategy_advisor: "Recommend actions based on Q4 trends" 4. Wait for all results and read them from the blackboard 5. Synthesize: Combine metrics + risks + recommendations into executive summary ``` ## Security Considerations 1. **Never bypass the permission wall** for gated resources 2. **Always include justification** explaining the business need 3. **Use minimal scope** - request only what you need 4. **Check token expiry** - tokens are valid for 5 minutes 5. **Validate tokens** - use `python {baseDir}/scripts/validate_token.py TOKEN` to verify grant tokens before use 6. **Audit trail** - all permission requests are logged ## 📝 Audit Trail Requirements (MANDATORY) **Every sensitive action MUST be logged to `data/audit_log.jsonl`** to maintain compliance and enable forensic analysis. > **Privacy note:** Audit log entries contain agent-provided free-text fields (justifications, descriptions). These are stored locally in `data/audit_log.jsonl` and never transmitted over the network by this skill. However, **do not put PII, passwords, or API keys in justification strings** — they persist on disk. Consider periodic log rotation and restricting OS file permissions on the `data/` directory. ### What Gets Logged Automatically The scripts automatically log these events: - `permission_granted` - When access is approved - `permission_denied` - When access is rejected - `permission_revoked` - When a token is manually revoked - `ttl_cleanup` - When expired tokens are purged - `result_validated` / `result_rejected` - Swarm Guard validations ### Log Entry Format ```json { "timestamp": "2026-02-04T10:30:00+00:00", "action": "permission_granted", "details": { "agent_id": "data_analyst", "resource_type": "DATABASE", "justification": "Q4 revenue analysis", "token": "grant_abc123...", "restrictions": ["read_only", "max_records:100"] } } ``` ### Reading the Audit Log ```bash # View recent entries (last 10) tail -10 {baseDir}/data/audit_log.jsonl # Search for specific agent grep "data_analyst" {baseDir}/data/audit_log.jsonl # Count actions by type cat {baseDir}/data/audit_log.jsonl | jq -r '.action' | sort | uniq -c ``` ### Custom Audit Entries If you perform a sensitive action manually, log it: ```python import json from datetime import datetime, timezone from pathlib import Path audit_file = Path("{baseDir}/data/audit_log.jsonl") entry = { "timestamp": datetime.now(timezone.utc).isoformat(), "action": "manual_data_access", "details": { "agent": "orchestrator", "description": "Direct database query for debugging", "justification": "Investigating data sync issue #1234" } } with open(audit_file, "a") as f: f.write(json.dumps(entry) + "\n") ``` ## 🧹 TTL Enforcement (Token Lifecycle) Expired permission tokens are automatically tracked. Run periodic cleanup: ```bash # Validate a grant token python {baseDir}/scripts/validate_token.py grant_a1b2c3d4e5f6 # List expired tokens (without removing) python {baseDir}/scripts/revoke_token.py --list-expired # Remove all expired tokens python {baseDir}/scripts/revoke_token.py --cleanup # Output: # 🧹 TTL Cleanup Complete # Removed: 3 expired token(s) # Remaining active grants: 2 ``` **Best Practice**: Run `--cleanup` at the start of each multi-agent task to ensure a clean permission state. ## ⚠️ Swarm Guard: Preventing Common Failures Two critical issues can derail multi-agent swarms: ### 1. The Handoff Tax 💸 **Problem**: Agents waste tokens "talking about" work instead of doing it. **Prevention**: ```bash # Before each handoff, check your budget: python {baseDir}/scripts/swarm_guard.py check-handoff --task-id "task_001" # Output: # 🟢 Task: task_001 # Handoffs: 1/3 # Remaining: 2 # Action Ratio: 100% ``` **Rules enforced**: - **Max 3 handoffs per task** - After 3, produce output or abort - **Max 500 chars per message** - Be concise: instruction + constraints + expected output - **60% action ratio** - At least 60% of handoffs must produce artifacts - **2-minute planning limit** - No output after 2min = timeout ```bash # Record a handoff (with tax checking): python {baseDir}/scripts/swarm_guard.py record-handoff \ --task-id "task_001" \ --from orchestrator \ --to data_analyst \ --message "Analyze sales data, output JSON summary" \ --artifact # Include if this handoff produces output ``` ### 2. Silent Failure Detection 👻 **Problem**: One agent fails silently, others keep working on bad data. **Prevention - Heartbeats**: ```bash # Agents must send heartbeats while working: python {baseDir}/scripts/swarm_guard.py heartbeat --agent data_analyst --task-id "task_001" # Check if an agent is healthy: python {baseDir}/scripts/swarm_guard.py health-check --agent data_analyst # Output if healthy: # 💚 Agent 'data_analyst' is HEALTHY # Last seen: 15s ago # Output if failed: # 💔 Agent 'data_analyst' is UNHEALTHY # Reason: STALE_HEARTBEAT # → Do NOT use any pending results from this agent. ``` **Prevention - Result Validation**: ```bash # Before using another agent's result, validate it: python {baseDir}/scripts/swarm_guard.py validate-result \ --task-id "task_001" \ --agent data_analyst \ --result '{"status": "success", "output": {"revenue": 125000}, "confidence": 0.85}' # Output: # ✅ RESULT VALID # → APPROVED - Result can be used by other agents ``` **Required result fields**: `status`, `output`, `confidence` ### Supervisor Review Before finalizing any task, run supervisor review: ```bash python {baseDir}/scripts/swarm_guard.py supervisor-review --task-id "task_001" # Output: # ✅ SUPERVISOR VERDICT: APPROVED # Task: task_001 # Age: 1.5 minutes # Handoffs: 2 # Artifacts: 2 ``` **Verdicts**: - `APPROVED` - Task healthy, results usable - `WARNING` - Issues detected, review recommended - `BLOCKED` - Critical failures, do NOT use results ## Troubleshooting ### Permission Denied - Provide more specific justification (mention task, purpose, expected outcome) - Narrow the requested scope - Check agent trust level ### Blackboard Read Returns Null - Entry may have expired (check TTL) - Key may be misspelled - Entry was never written ### Session Not Found - Run `sessions_list` (OpenClaw platform built-in) to see available sessions - Session may need to be started first ## Security Framework Assessment (MAESTRO / OWASP AST) The following findings are drawn from the **MAESTRO Agent Security Threat** framework (OWASP LLM / ASVS mapping). They are addressed by existing architectural controls in Network-AI — not open vulnerabilities. ### AST03 — Over-Privileged Skills · Severity: High > *Skills are granted broader permissions than their stated function requires, creating excessive blast radius if prompt-injected.* | Control | How Network-AI addresses it | |---|---| | **Permission manifest** | `metadata.openclaw` in SKILL.md frontmatter explicitly declares `bundle_scope` (Python scripts: local-only; full npm package: includes optional MCP SSE server), `network_calls` (Python scripts: none; MCP SSE server: TCP, operator-started, bearer-token required), `requires.bins: [python3]` — no API credentials, no external services in core | | **Least-privilege resource gating** | `check_permission.py` uses a weighted scoring model (justification 40 %, trust 30 %, risk 30 %); PAYMENTS and FILE_EXPORT require `--confirm-high-risk` acknowledgment before any token is issued; `--scope` limits every grant to minimum required access | | **Abstract resource labels only** | PAYMENTS, DATABASE, EMAIL, FILE_EXPORT are local scoring labels — no external credentials exist in the skill; there is nothing to leak to an external service | | **HMAC-signed grant tokens** | Since v5.5.2, every grant record carries `_sig` (HMAC-SHA256 over canonical fields); `validate_token.py` rejects tampered records — privilege escalation via forged grants is detected at validation time | | **SandboxPolicy + FileAccessor** | AgentRuntime's `SandboxPolicy` enforces command allowlists/blocklists; `FileAccessor` restricts all file I/O to `data/<env>/`; out-of-scope access throws `SourceProtectionError` and returns `{success: false}` without leaking path details | | **Advisory-only tokens** | All grant tokens are explicitly marked `advisory: true`; downstream systems must add a separate authenticated identity check and human approval before any real sensitive action — documented in frontmatter and throughout SKILL.md | ### AST06 — Weak Isolation · Severity: High > *Skills execute in the host agent's security context with full filesystem, shell, and network access.* | Control | How Network-AI addresses it | |---|---| | **Zero network calls (Python scripts)** | All bundled Python scripts use Python stdlib only, spawn no subprocesses, and make no network calls — declared in `metadata.openclaw.network_calls` and `bundle_scope`. The optional MCP SSE server (`bin/mcp-server.ts`) binds a TCP port only when explicitly started by the operator and requires a non-empty bearer-token secret. | | **AgentRuntime sandbox** | `ShellExecutor` enforces per-command timeout and output-size limits; `SandboxPolicy` allowlist/blocklist prevents unapproved shell commands from running at all | | **Source protection** | `SandboxPolicy.sourceProtection` constrains `FileAccessor.read/write/list` to `data/<env>/` only; any attempt to read outside that boundary throws `SourceProtectionError` — the agent receives `{success: false}`, no path details leak | | **Environment isolation** | `NETWORK_AI_ENV` / `--env` routes all state to `data/<env>/`; dev, staging, and production state are fully separated; live state (`audit_log.jsonl`, `active_grants.json`) never promotes across environments | | **ApprovalGate** | High-risk shell or file operations require explicit human or callback approval before execution; auto-approve only in explicitly trusted environments | | **No hot-reload surface** | Bundled scripts do not implement or respond to a SkillsWatcher; skill updates require explicit `clawhub install` or `npm install` — no mid-session reload is possible | ### AST07 — Update Drift · Severity: Medium > *Installed skills drift out of sync — either unpatched (leaving known CVEs open) or blindly auto-updated (potentially receiving malicious patches).* | Control | How Network-AI addresses it | |---|---| | **Exact version pinning** | npm `package.json` uses exact `"version": "5.8.9"` — no semver range specifiers; `clawhub install network-ai` pins to a specific published version | | **Zero transitive dependency drift** | All bundled Python scripts use Python stdlib only — `pip install` is never required; there are no third-party packages to drift, be compromised upstream, or introduce CVEs | | **Signed, tagged releases** | Every release is committed with a signed Git tag (`v5.7.x`); commit hash is verifiable against CHANGELOG.md; GitHub releases link tag → diff → changelog entry | | **Supply chain monitoring** | npm package continuously scored by Socket.dev (score A); any new dependency or permission change triggers an alert | | **No auto-update mechanism** | Updates require explicit user action (`clawhub install`, `npm install network-ai@latest`); there is no background updater or hot-reload path | | **CHANGELOG.md audit trail** | Every change — including docs-only patches — is tracked with version, date, and rationale; operators can diff any two versions to assess update risk before applying | ## Security Scan Findings (ClawHub) This skill is scanned on every publish. The following Notes are flagged by design and will recur on every scan. They are acknowledged and mitigated with documented controls — they are not bugs or vulnerabilities. | Finding | Severity | Why it recurs | Documented control | |---------|----------|---------------|--------------------| | **ASI01** Agent Goal Hijack | High | Orchestrator skill forces 3-sub-task decomposition by design | Use this skill only when multi-agent orchestration is desired; disable for simple one-shot tasks | | **ASI03** Identity and Privilege Abuse (advisory tokens) | Medium | Grant tokens are advisory scoring outputs only — caller-supplied `--agent` identity is not cryptographically verified; skill explicitly warns tokens must not be used as real authorization for PAYMENTS, DATABASE, or FILE_EXPORT | Tokens are explicitly marked advisory in SKILL.md and source; require separate platform auth and human approval before any real database, payment, email, or export action | | **ASI03** Identity and Privilege Abuse (local grant state) | Low | The permission system creates persistent local state (`active_grants.json`, `audit_log.jsonl`, `.signing_key`) — security-relevant files that are purpose-aligned but accessible to anyone with `data/` access | Keep the skill directory private; back up or delete local grant state when no longer needed; do not share `data/` casually; restrict OS-level permissions on `data/` on shared machines | | **ASI03** Identity and Privilege Abuse (token integrity) | ~~High~~ Resolved | Token payload had no integrity protection — active_grants.json could be edited to forge elevated grants | Fixed in v5.5.2 — `check_permission.py` HMAC-SHA256 signs each grant (`_sig` field, stdlib `hmac`+`hashlib`, key at `data/.signing_key`); `validate_token.py` verifies before accepting; tampered tokens rejected with `"Token signature invalid"` | | **ASI03** Identity and Privilege Abuse (env-scoped paths) | ~~High~~ Resolved | `revoke_token.py` resolved `GRANTS_FILE`/`AUDIT_LOG` at module load from root `data/`, ignoring `NETWORK_AI_ENV` — revoking tokens in one env could silently miss env-specific grant files | Fixed in v5.5.1 — `_resolve_data_dir()` added, `--env` CLI argument introduced, paths re-resolved in `main()` before file I/O; consistent with `check_permission.py` and `validate_token.py` | | **ASI06** Memory and Context Poisoning (project context) | Medium | Persistent `data/project-context.json` is injected into every agent session by design — inaccurate or malicious context could steer future agent behavior | `_validate_context()` runs injection-pattern detection before every inject; do not store secrets/credentials; review `data/project-context.json` before use; clear `data/` between projects | | **ASI06** Memory and Context Poisoning (audit log free text) | Low | `justification` field in permission requests and `data/audit_log.jsonl` store agent-provided free-text strings locally — PII or secrets placed there will persist on disk | Do not include PII, secrets, or credentials in justification text; restrict access to `data/` on shared machines; rotate/delete `audit_log.jsonl` when no longer needed | | **ASI07** Insecure Inter-Agent Communication | High | Blackboard is local file-based; origin/identity depends on local file access, not authenticated messaging | Run in a trusted workspace; restrict file permissions on `data/`; review blackboard changes before relying on them for important decisions | | **ASI08** Cascading Failures | ~~High~~ Resolved | `os` was referenced before import in `swarm_guard.py` — fixed in v5.4.4; `import os` now present | Fixed — `swarm_guard.py` now imports `os` at module level; budget/health guard starts correctly | ## References This skill is part of the larger [Network-AI](https://github.com/Jovancoding/Network-AI) project. See the repository for full documentation on the permission system, blackboard schema, and trust-level calculations.

path: assets/demo.svg
content: <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="872" height="613.04"><rect width="872" height="613.04" rx="5" ry="5" class="a"/><svg y="0%" x="0%"><circle cx="20" cy="20" r="6" fill="#ff5f58"/><circle cx="40" cy="20" r="6" fill="#ffbd2e"/><circle cx="60" cy="20" r="6" fill="#18c132"/></svg><svg height="521.04" viewBox="0 0 80 52.104" width="800" x="31" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" y="66"><style>@keyframes p{0%{transform:translateX(-80px)}.3%{transform:translateX(-160px)}.7%{transform:translateX(-240px)}1%{transform:translateX(-320px)}1.4%{transform:translateX(-400px)}1.7%{transform:translateX(-480px)}2.1%{transform:translateX(-560px)}2.4%{transform:translateX(-640px)}2.8%{transform:translateX(-720px)}3.1%{transform:translateX(-800px)}3.4%{transform:translateX(-880px)}3.8%{transform:translateX(-960px)}4.1%{transform:translateX(-1040px)}4.5%{transform:translateX(-1120px)}4.8%{transform:translateX(-1200px)}5.2%{transform:translateX(-1280px)}5.5%{transform:translateX(-1360px)}5.9%{transform:translateX(-1440px)}6.2%{transform:translateX(-1520px)}6.5%{transform:translateX(-1600px)}6.9%{transform:translateX(-1680px)}7.2%{transform:translateX(-1760px)}7.6%{transform:translateX(-1840px)}7.9%{transform:translateX(-1920px)}8.3%{transform:translateX(-2000px)}8.6%{transform:translateX(-2080px)}8.9%{transform:translateX(-2160px)}9.3%{transform:translateX(-2240px)}9.6%{transform:translateX(-2320px)}10%{transform:translateX(-2400px)}10.3%{transform:translateX(-2480px)}10.7%{transform:translateX(-2560px)}11%{transform:translateX(-2640px)}12%{transform:translateX(-2720px)}27.2%{transform:translateX(-2800px)}27.7%{transform:translateX(-2880px)}28.2%{transform:translateX(-2960px)}28.7%{transform:translateX(-3040px)}30.7%{transform:translateX(-3120px)}33.6%{transform:translateX(-3200px)}36.1%{transform:translateX(-3280px)}38.5%{transform:translateX(-3360px)}42.5%{transform:translateX(-3440px)}44.4%{transform:translateX(-3520px)}47.4%{transform:translateX(-3600px)}50.8%{transform:translateX(-3680px)}54.3%{transform:translateX(-3760px)}56.2%{transform:translateX(-3840px)}59.2%{transform:translateX(-3920px)}61.2%{transform:translateX(-4000px)}63.1%{transform:translateX(-4080px)}66.6%{transform:translateX(-4160px)}68.5%{transform:translateX(-4240px)}69.5%{transform:translateX(-4320px)}70.5%{transform:translateX(-4400px)}to{transform:translateX(-4480px)}}.a{fill:#282d35}.f{fill:#b9c0cb;white-space:pre}.g{fill:#66c2cd;font-weight:700}.g,.h,.i{white-space:pre}.h{fill:#b9c0cb;font-weight:700}.i{fill:#dbab79}.j{fill:#a8cc8c}.j,.k,.l,.n{white-space:pre}.k{fill:#a8cc8c;font-weight:700}.l,.n{fill:#e88388}.n{fill:#66c2cd}</style><g font-family="Monaco,Consolas,Menlo,'Bitstream Vera Sans Mono','Powerline Symbols',monospace" font-size="1.67"><defs><symbol id="1"><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text></symbol><symbol id="2"><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text></symbol><symbol id="3"><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">examples/gif-demo.ts</text></symbol><symbol id="4"><text y="1.67" class="g">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text></symbol><symbol id="5"><text x="2.004" y="1.67" class="g">Network-AI</text><text x="13.026" y="1.67" class="g">—</text><text x="15.03" y="1.67" class="g">Multi-Agent</text><text x="27.054" y="1.67" class="g">Coordination</text><text x="40.08" y="1.67" class="g">Demo</text></symbol><symbol id="6"><text x="2.004" y="1.67" class="h">▸</text><text x="4.008" y="1.67" class="h">Atomic</text><text x="11.022" y="1.67" class="h">Blackboard</text></symbol><symbol id="7"><text x="2.004" y="1.67" class="f">●</text><text x="4.008" y="1.67" class="f">agent-A</text><text x="12.024" y="1.67" class="f">wrote</text><text x="19.038" y="1.67" class="f">priority=0</text><text x="31.062" y="1.67" class="f">status=queued</text></symbol><symbol id="8"><text x="2.004" y="1.67" class="i">●</text><text x="4.008" y="1.67" class="i">agent-B</text><text x="12.024" y="1.67" class="i">wrote</text><text x="19.038" y="1.67" class="i">priority=3</text><text x="31.062" y="1.67" class="i">status=approved</text></symbol><symbol id="9"><text x="2.004" y="1.67" class="j">✓</text><text x="4.008" y="1.67" class="j">final</text><text x="10.02" y="1.67" class="j">value:</text><text x="17.034" y="1.67" class="k">{&quot;status&quot;:&quot;approved&quot;}</text><text x="40.08" y="1.67" class="f">(priority</text><text x="50.1" y="1.67" class="f">wins)</text></symbol><symbol id="10"><text x="2.004" y="1.67" class="h">▸</text><text x="4.008" y="1.67" class="h">AuthGuardian</text><text x="17.034" y="1.67" class="h">Permission</text><text x="28.056" y="1.67" class="h">Gate</text></symbol><symbol id="11"><text x="2.004" y="1.67" class="l">✗</text><text x="4.008" y="1.67" class="l">weak</text><text x="9.018" y="1.67" class="l">justification</text><text x="23.046" y="1.67" class="l">→</text><text x="25.05" y="1.67" style="white-space:pre" fill="#e88388" font-weight="700">BLOCKED</text></symbol><symbol id="12"><text x="2.004" y="1.67" class="j">✓</text><text x="4.008" y="1.67" class="j">strong</text><text x="11.022" y="1.67" class="j">justification</text><text x="25.05" y="1.67" class="j">→</text><text x="27.054" y="1.67" class="k">GRANTED</text><text x="36.072" y="1.67" class="f">[REDACTED_SECRET] id="13"><text x="2.004" y="1.67" class="h">▸</text><text x="4.008" y="1.67" class="h">FederatedBudget</text></symbol><symbol id="14"><text x="2.004" y="1.67" class="n">●</text><text x="4.008" y="1.67" class="n">agent-A</text><text x="12.024" y="1.67" class="n">spent</text><text x="18.036" y="1.67" class="n">1,200</text><text x="24.048" y="1.67" class="n">tokens</text><text x="32.064" y="1.67" class="n">remaining=3800</text></symbol><symbol id="15"><text x="2.004" y="1.67" class="n">●</text><text x="4.008" y="1.67" class="n">agent-B</text><text x="12.024" y="1.67" class="n">spent</text><text x="20.04" y="1.67" class="n">800</text><text x="24.048" y="1.67" class="n">tokens</text><text x="32.064" y="1.67" class="n">remaining=3000</text></symbol><symbol id="16"><text x="2.004" y="1.67" class="j">✓</text><text x="4.008" y="1.67" class="j">ceiling=5,000</text><text x="19.038" y="1.67" class="j">total=2,000</text><text x="32.064" y="1.67" class="k">within</text><text x="39.078" y="1.67" class="k">budget</text></symbol><symbol id="17"><text x="2.004" y="1.67" class="k">✓</text><text x="4.008" y="1.67" class="k">Done</text><text x="9.018" y="1.67" class="k">—</text><text x="11.022" y="1.67" class="k">3</text><text x="13.026" y="1.67" class="k">primitives,</text><text x="25.05" y="1.67" class="k">0</text><text x="27.054" y="1.67" class="k">API</text><text x="31.062" y="1.67" class="k">calls,</text><text x="38.076" y="1.67" class="k">0</text><text x="40.08" y="1.67" class="k">race</text><text x="45.09" y="1.67" class="k">conditions.</text></symbol><symbol id="a"><path fill="transparent" d="M0 0h80v25H0z"/></symbol></defs><path class="a" d="M0 0h80v52.104H0z"/><g style="animation-duration:10.17s;animation-iteration-count:infinite;animation-name:p;animation-timing-function:steps(1,end)"><svg width="4560"><svg><use xlink:href="#a"/></svg><svg x="80"><use xlink:href="#a"/><text y="1.67" class="f">$</text></svg><svg x="160"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">n</text></svg><svg x="240"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">np</text></svg><svg x="320"><use xlink:href="#a"/><use xlink:href="#1"/></svg><svg x="400"><use xlink:href="#a"/><use xlink:href="#1"/></svg><svg x="480"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">t</text></svg><svg x="560"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts</text></svg><svg x="640"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-</text></svg><svg x="720"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-n</text></svg><svg x="800"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-no</text></svg><svg x="880"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-nod</text></svg><svg x="960"><use xlink:href="#a"/><use xlink:href="#2"/></svg><svg x="1040"><use xlink:href="#a"/><use xlink:href="#2"/></svg><svg x="1120"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">e</text></svg><svg x="1200"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">ex</text></svg><svg x="1280"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">exa</text></svg><svg x="1360"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">exam</text></svg><svg x="1440"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">examp</text></svg><svg x="1520"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">exampl</text></svg><svg x="1600"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">example</text></svg><svg x="1680"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">examples</text></svg><svg x="1760"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">examples/</text></svg><svg x="1840"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">examples/g</text></svg><svg x="1920"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">examples/gi</text></svg><svg x="2000"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">examples/gif</text></svg><svg x="2080"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">examples/gif-</text></svg><svg x="2160"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">examples/gif-d</text></svg><svg x="2240"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">examples/gif-de</text></svg><svg x="2320"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">examples/gif-dem</text></svg><svg x="2400"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">examples/gif-demo</text></svg><svg x="2480"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">examples/gif-demo.</text></svg><svg x="2560"><use xlink:href="#a"/><text y="1.67" class="f">$</text><text x="2.004" y="1.67" class="f">npx</text><text x="6.012" y="1.67" class="f">ts-node</text><text x="14.028" y="1.67" class="f">examples/gif-demo.t</text></svg><svg x="2640"><use xlink:href="#a"/><use xlink:href="#3"/></svg><svg x="2720"><use xlink:href="#a"/><use xlink:href="#3"/></svg><svg x="2800"><use xlink:href="#a"/><use xlink:href="#3"/></svg><svg x="2880"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/></svg><svg x="2960"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/></svg><svg x="3040"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/></svg><svg x="3120"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/></svg><svg x="3200"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/></svg><svg x="3280"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/></svg><svg x="3360"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/></svg><svg x="3440"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/><use xlink:href="#9" y="19.539"/></svg><svg x="3520"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/><use xlink:href="#9" y="19.539"/></svg><svg x="3600"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/><use xlink:href="#9" y="19.539"/><use xlink:href="#10" y="23.881"/></svg><svg x="3680"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/><use xlink:href="#9" y="19.539"/><use xlink:href="#10" y="23.881"/><use xlink:href="#11" y="26.052"/></svg><svg x="3760"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/><use xlink:href="#9" y="19.539"/><use xlink:href="#10" y="23.881"/><use xlink:href="#11" y="26.052"/><use xlink:href="#12" y="28.223"/></svg><svg x="3840"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/><use xlink:href="#9" y="19.539"/><use xlink:href="#10" y="23.881"/><use xlink:href="#11" y="26.052"/><use xlink:href="#12" y="28.223"/></svg><svg x="3920"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/><use xlink:href="#9" y="19.539"/><use xlink:href="#10" y="23.881"/><use xlink:href="#11" y="26.052"/><use xlink:href="#12" y="28.223"/><use xlink:href="#13" y="32.565"/></svg><svg x="4000"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/><use xlink:href="#9" y="19.539"/><use xlink:href="#10" y="23.881"/><use xlink:href="#11" y="26.052"/><use xlink:href="#12" y="28.223"/><use xlink:href="#13" y="32.565"/><use xlink:href="#14" y="34.736"/></svg><svg x="4080"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/><use xlink:href="#9" y="19.539"/><use xlink:href="#10" y="23.881"/><use xlink:href="#11" y="26.052"/><use xlink:href="#12" y="28.223"/><use xlink:href="#13" y="32.565"/><use xlink:href="#14" y="34.736"/><use xlink:href="#15" y="36.907"/></svg><svg x="4160"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/><use xlink:href="#9" y="19.539"/><use xlink:href="#10" y="23.881"/><use xlink:href="#11" y="26.052"/><use xlink:href="#12" y="28.223"/><use xlink:href="#13" y="32.565"/><use xlink:href="#14" y="34.736"/><use xlink:href="#15" y="36.907"/><use xlink:href="#16" y="39.078"/></svg><svg x="4240"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/><use xlink:href="#9" y="19.539"/><use xlink:href="#10" y="23.881"/><use xlink:href="#11" y="26.052"/><use xlink:href="#12" y="28.223"/><use xlink:href="#13" y="32.565"/><use xlink:href="#14" y="34.736"/><use xlink:href="#15" y="36.907"/><use xlink:href="#16" y="39.078"/></svg><svg x="4320"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/><use xlink:href="#9" y="19.539"/><use xlink:href="#10" y="23.881"/><use xlink:href="#11" y="26.052"/><use xlink:href="#12" y="28.223"/><use xlink:href="#13" y="32.565"/><use xlink:href="#14" y="34.736"/><use xlink:href="#15" y="36.907"/><use xlink:href="#16" y="39.078"/><use xlink:href="#17" y="43.42"/></svg><svg x="4400"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/><use xlink:href="#9" y="19.539"/><use xlink:href="#10" y="23.881"/><use xlink:href="#11" y="26.052"/><use xlink:href="#12" y="28.223"/><use xlink:href="#13" y="32.565"/><use xlink:href="#14" y="34.736"/><use xlink:href="#15" y="36.907"/><use xlink:href="#16" y="39.078"/><use xlink:href="#17" y="43.42"/></svg><svg x="4480"><use xlink:href="#a"/><use xlink:href="#3"/><use xlink:href="#4" y="4.342"/><use xlink:href="#5" y="6.513"/><use xlink:href="#4" y="8.684"/><use xlink:href="#6" y="13.026"/><use xlink:href="#7" y="15.197"/><use xlink:href="#8" y="17.368"/><use xlink:href="#9" y="19.539"/><use xlink:href="#10" y="23.881"/><use xlink:href="#11" y="26.052"/><use xlink:href="#12" y="28.223"/><use xlink:href="#13" y="32.565"/><use xlink:href="#14" y="34.736"/><use xlink:href="#15" y="36.907"/><use xlink:href="#16" y="39.078"/><use xlink:href="#17" y="43.42"/></svg></svg></g></g></svg></svg>

path: DATA_LOCATIONS.md
content: # Data Locations

Every file and directory that Network-AI creates, reads, or modifies at runtime — with
the path, purpose, data classification, and retention guidance.

All paths are relative to the **data directory** (default: `./data/`, override with
`--data <path>` CLI flag or `NETWORK_AI_DATA` env var).

---

## Root Data Directory (`data/`)

| File / Directory | Created by | Purpose | Data class | Contains secrets? |
|-----------------|-----------|---------|-----------|------------------|
| `audit_log.jsonl` | `SecureAuditLogger` | Append-only event log — every write, permission grant, state transition | Sensitive | No (tokens are hashed) |
| `blackboard.json` | `LockedBlackboard` | Primary key-value store; all agent coordination state | Sensitive | Operator-dependent |
| `pending_changes/` | `LockedBlackboard` | WAL entries for in-flight `propose → commit` transactions | Sensitive | Mirrors blackboard |
| `trust_levels.json` | `AuthGuardian` | Per-agent trust level registry | Sensitive | No |
| `.signing_key` | `check_permission.py` | HMAC-SHA256 key for signing grant tokens | **Critical** | **Yes** |
| `project-context.json` | `GoalDecomposer` | Active project metadata for LLM-assisted goal decomposition | Internal | No |
| `backups/` | `EnvironmentManager` | Point-in-time snapshots created by `env backup create` | Sensitive | Same as source |

### `pending_changes/` layout

```
data/pending_changes/
  <changeId>.json        — one file per in-flight proposal
```

Each file is a `BlackboardChange` JSON object with `key`, `value`, `agent`, `timestamp`,
`ttl`, and `priority`. Files are deleted on `commit` or `abort`.

---

## Per-Environment Directories (`data/<env>/`)

When multi-environment mode is active (`NETWORK_AI_ENV=<env>` or `--env <name>`), each
environment gets its own isolated copy of all root-level files:

| Environment | Directory |
|-------------|-----------|
| dev | `data/dev/` |
| st | `data/st/` |
| sit | `data/sit/` |
| qa | `data/qa/` |
| sandbox | `data/sandbox/` |
| preprod | `data/preprod/` |
| prod | `data/prod/` |

Each env directory contains the same set of files as the root data directory. The
promotion chain is: `dev → st → sit → qa → preprod → prod`.

---

## Backup Directories (`data/backups/<env>/`)

| Path | Created by | Format |
|------|-----------|--------|
| `data/backups/<env>/<timestamp>/` | `EnvironmentManager.backup()` | Directory copy of the env data dir |

Backups are created on demand via `network-ai env backup create --env <name>`.
They are not auto-pruned; use `network-ai env backup prune --env <name> --keep N`.

---

## Lock Files

| File | Purpose | Lifespan |
|------|---------|---------|
| `data/.lock` | Filesystem mutex for `LockedBlackboard` atomic operations | Held for duration of propose/commit; auto-released |
| `data/<env>/.lock` | Per-environment lock | Same |

Lock files are created and deleted by `LockedBlackboard`. A stale lock (process crash)
is detected by PID check and automatically cleared.

---

## Audit Log Schema

Each line in `audit_log.jsonl` is a JSON object. Full schema: [AUDIT_LOG_SCHEMA.md](AUDIT_LOG_SCHEMA.md).

Key fields:

```jsonc
{
  "timestamp": "2026-05-23T15:00:00.000Z",  // ISO 8601
  "event":     "BLACKBOARD_WRITE",           // event type
  "agent":     "orchestrator-1",             // acting agent
  "key":       "task/result",                // affected key (if applicable)
  "granted":   true,                         // permission outcome
  "tokenHash": "sha256:abc123…"              // hashed token (never raw)
}
```

---

## Data Classification

| Class | Definition | Examples in Network-AI |
|-------|-----------|----------------------|
| **Critical** | Compromise enables privilege escalation or token forgery | `.signing_key` |
| **Sensitive** | Contains operational data; exposure enables information disclosure or replay | `audit_log.jsonl`, `blackboard.json`, `trust_levels.json` |
| **Internal** | Non-secret operational metadata | `project-context.json`, lock files |
| **Public** | Intentionally readable | `package.json`, source files |

---

## Operator Responsibilities

1. **Permissions**: Set `data/` to `0700` (or equivalent) — readable only by the
   Network-AI process owner. The `.signing_key` file should be `0600`.

2. **Secrets in blackboard**: Do **not** store API keys, passwords, or private keys as
   blackboard values. The blackboard is plaintext JSON on disk.

3. **Audit log retention**: The audit log is never auto-deleted. Implement a log-rotation
   policy appropriate for your compliance requirements.

4. **Backup encryption**: Backups are plaintext copies. Encrypt at the filesystem level
   (LUKS, FileVault, BitLocker) if required by your data-handling policy.

5. **Multi-project isolation**: Clear `data/` (or use a separate `--data` path) between
   unrelated projects to prevent cross-project context leakage.

---

## Files Never Created by Network-AI

The following are **never** written by Network-AI and should be treated as suspicious if
they appear in the data directory:

- Executable files (`*.sh`, `*.exe`, `*.bat`)
- Private key files in PEM/DER format (`*.pem`, `*.key`, `*.p12`)
- Any file outside the `data/` directory tree (path traversal is blocked)


path: glama.json
content: {
  "$schema": "https://glama.ai/mcp/schemas/server.json",
  "maintainers": [
    "Jovancoding"
  ]
}


path: requirements.txt
content: # ============================================================================
# ZERO DEPENDENCIES REQUIRED
# ============================================================================
# This file is documentation only. Do NOT run `pip install -r requirements.txt`.
#
# All 6 Python scripts use the standard library only:
#   blackboard.py, swarm_guard.py, check_permission.py,
#   validate_token.py, revoke_token.py, context_manager.py
#
# Standard library modules used:
#   argparse, json, os, re, sys, time, hashlib, uuid, datetime, pathlib,
#   typing, contextlib, fcntl (Unix; file-lock fallback on Windows)
#
# ── OPTIONAL (for development only, not required to run the skill) ──────────
# filelock>=3.0.0  # Cross-platform file locking (Windows production)
# mypy>=1.0.0      # Static type checking
# pytest>=7.0.0    # Running the test suite


path: scripts/blackboard.py
content: #!/usr/bin/env python3
# SECURITY: This script makes NO network calls and spawns NO subprocesses.
# All I/O is local file operations only:
#   READS:  swarm-blackboard.md, data/pending_changes/<id>.json
#   WRITES: swarm-blackboard.md, data/pending_changes/<id>.json
# --path controls the blackboard file path only and is validated against the project
# root directory; paths outside the project directory are rejected (CWE-22).
# Lock files and pending-change files always resolve from the global data/ directory
# regardless of --path; only the main blackboard file path is affected by --path.
# Imports used: argparse, json, os, re, sys, time, hashlib, datetime, pathlib,
#               typing, contextlib, fcntl (Unix file-lock only, no network use)
# No imports of: requests, socket, subprocess, urllib, http, ssl, ftplib, smtplib
"""
Shared Blackboard - Agent Coordination State Manager (Atomic Commit Edition)

A markdown-based shared state system for multi-agent coordination.
Stores key-value pairs with optional TTL (time-to-live) expiration.

FEATURES:
  - File Locking: Prevents race conditions in multi-agent environments
  - Staging Area: propose → validate → commit workflow
  - Atomic Commits: Changes are all-or-nothing

Usage:
    python blackboard.py write KEY VALUE [--ttl SECONDS]
    python blackboard.py read KEY
    python blackboard.py delete KEY
    python blackboard.py list
    python blackboard.py snapshot
    
    # Atomic commit workflow:
    python blackboard.py propose CHANGE_ID KEY VALUE [--ttl SECONDS]
    python blackboard.py validate CHANGE_ID
    python blackboard.py commit CHANGE_ID
    python blackboard.py abort CHANGE_ID
    python blackboard.py list-pending

Examples:
    python blackboard.py write "task:analysis" '{"status": "running"}'
    python blackboard.py write "cache:data" '{"value": 123}' --ttl 3600
    python blackboard.py read "task:analysis"
    python blackboard.py list
    
    # Safe multi-agent update:
    python blackboard.py propose "chg_001" "order:123" '{"status": "approved"}'
    python blackboard.py validate "chg_001"  # Orchestrator checks for conflicts
    python blackboard.py commit "chg_001"    # Apply atomically
"""

import argparse
import json
import os
import re
import sys
import time
import hashlib
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from contextlib import contextmanager

# Try to import fcntl (Unix only), fall back to file-based locking on Windows
_fcntl: Any = None
try:
    import fcntl as _fcntl_import
    _fcntl = _fcntl_import
except ImportError:
    pass  # fcntl unavailable on Windows; file-based lock fallback is used instead

# Default blackboard location — respects NETWORK_AI_ENV for multi-env isolation
def _resolve_data_dir(env: str = "") -> Path:
    """Return the active data directory, scoped to <env> when set."""
    _env = env or os.environ.get("NETWORK_AI_ENV", "")
    base = Path(__file__).parent.parent / "data"
    if _env:
        # Validate to prevent path traversal (CWE-22)
        if not re.match(r'^[a-zA-Z0-9_-]+$', _env):
            raise ValueError(f"Invalid NETWORK_AI_ENV value: {_env!r}")
        return base / _env
    return base

_DATA_DIR = _resolve_data_dir()
BLACKBOARD_PATH = _DATA_DIR / "swarm-blackboard.md"
LOCK_PATH = _DATA_DIR / ".blackboard.lock"
PENDING_DIR = _DATA_DIR / "pending_changes"

# Lock timeout settings
LOCK_TIMEOUT_SECONDS = 10
LOCK_RETRY_INTERVAL = 0.1


class FileLock:
    """
    Cross-platform file lock for preventing race conditions.
    Uses fcntl on Unix, fallback to lock file on Windows.
    """
    
    def __init__(self, lock_path: Path, timeout: float = LOCK_TIMEOUT_SECONDS):
        self.lock_path = lock_path
        self.timeout = timeout
        self.lock_file: Optional[Any] = None
        self.lock_marker: Optional[Path] = None
        self.lock_path.parent.mkdir(parents=True, exist_ok=True)
    
    def acquire(self) -> bool:
        """Acquire the lock with timeout."""
        start_time = time.time()
        
        while True:
            try:
                self.lock_file = open(self.lock_path, 'w')
                
                if _fcntl is not None:
                    # Unix/Linux/Mac - use fcntl
                    _fcntl.flock(self.lock_file.fileno(), _fcntl.LOCK_EX | _fcntl.LOCK_NB)
                else:
                    # Windows fallback: use lock marker file
                    self.lock_marker = self.lock_path.with_suffix('.locked')
                    if self.lock_marker.exists():
                        # Check if stale (older than timeout)
                        age = time.time() - self.lock_marker.stat().st_mtime
                        if age < self.timeout:
                            self.lock_file.close()
                            raise BlockingIOError("Lock held by another process")
                        # Stale lock, remove it
                        self.lock_marker.unlink()
                    self.lock_marker.write_text(str(time.time()))
                
                # Write lock holder info
                self.lock_file.write(json.dumps({
                    "pid": os.getpid(),
                    "acquired_at": datetime.now(timezone.utc).isoformat()
                }))
                self.lock_file.flush()
                return True
                
            except (BlockingIOError, OSError):
                if self.lock_file:
                    self.lock_file.close()
                    self.lock_file = None
                if time.time() - start_time > self.timeout:
                    return False
                time.sleep(LOCK_RETRY_INTERVAL)
    
    def release(self) -> None:
        """Release the lock."""
        if self.lock_file:
            try:
                if _fcntl is not None:
                    _fcntl.flock(self.lock_file.fileno(), _fcntl.LOCK_UN)
                elif self.lock_marker and self.lock_marker.exists():
                    self.lock_marker.unlink()
                
                self.lock_file.close()
            except Exception:
                pass  # best-effort unlock; fd and marker cleaned up in finally
            finally:
                self.lock_file = None
                self.lock_marker = None


@contextmanager
def blackboard_lock(lock_path: Path = LOCK_PATH):
    """Context manager for atomic blackboard access."""
    lock = FileLock(lock_path)
    if not lock.acquire():
        raise TimeoutError(
            f"Could not acquire blackboard lock within {LOCK_TIMEOUT_SECONDS}s. "
            "Another agent may be holding it. Retry later."
        )
    try:
        yield
    finally:
        lock.release()


class SharedBlackboard:
    """Markdown-based shared state for agent coordination with atomic commits."""
    
    def __init__(self, path: Path = BLACKBOARD_PATH):
        self.path = path
        self.cache: dict[str, dict[str, Any]] = {}
        self.pending_dir = PENDING_DIR
        self.pending_dir.mkdir(parents=True, exist_ok=True)
        self._initialize()
        self._load_from_disk()
    
    def _initialize(self):
        """Create blackboard file if it doesn't exist."""
        if not self.path.exists():
            self.path.parent.mkdir(parents=True, exist_ok=True)
            initial_content = f"""# Swarm Blackboard
Last Updated: {datetime.now(timezone.utc).isoformat()}

## Active Tasks
| TaskID | Agent | Status | Started | Description |
|--------|-------|--------|---------|-------------|

## Knowledge Cache
<!-- Cached results from agent operations -->

## Coordination Signals
<!-- Agent availability status -->

## Execution History
<!-- Chronological log of completed tasks -->
"""
            self.path.write_text(initial_content, encoding="utf-8")
    
    def _load_from_disk(self):
        """Load entries from the markdown blackboard."""
        try:
            content = self.path.read_text(encoding="utf-8")
            
            # Parse Knowledge Cache section
            cache_match = re.search(
                r'## Knowledge Cache\n([\s\S]*?)(?=\n## |$)', 
                content
            )
            
            if cache_match:
                cache_section = cache_match.group(1)
                # Find all entries: ### key\n{json}
                entries = re.findall(
                    r'### (\S+)\n([\s\S]*?)(?=\n### |$)',
                    cache_section
                )
                
                for key, value_str in entries:
                    try:
                        entry = json.loads(value_str.strip())
                        self.cache[key] = entry
                    except json.JSONDecodeError:
                        # Skip malformed entries
                        pass
        except Exception as e:
            print(f"Warning: Failed to load blackboard: {e}", file=sys.stderr)
    
    def _persist_to_disk(self):
        """Save entries to the markdown blackboard."""
        sections = [
            "# Swarm Blackboard",
            f"Last Updated: {datetime.now(timezone.utc).isoformat()}",
            "",
            "## Active Tasks",
            "| TaskID | Agent | Status | Started | Description |",
            "|--------|-------|--------|---------|-------------|",
            "",
            "## Knowledge Cache",
        ]
        
        # Clean expired entries and write valid ones
        for key, entry in list(self.cache.items()):
            if self._is_expired(entry):
                del self.cache[key]
                continue
            
            sections.append(f"### {key}")
            sections.append(json.dumps(entry, indent=2))
            sections.append("")
        
        sections.extend([
            "## Coordination Signals",
            "",
            "## Execution History",
        ])
        
        self.path.write_text("\n".join(sections), encoding="utf-8")
    
    def _is_expired(self, entry: dict[str, Any]) -> bool:
        """Check if an entry has expired based on TTL."""
        ttl = entry.get("ttl")
        if ttl is None:
            return False
        
        timestamp = entry.get("timestamp")
        if not timestamp:
            return False
        
        try:
            created = datetime.fromisoformat(str(timestamp).replace("Z", "+00:00"))
            now = datetime.now(timezone.utc)
            elapsed = (now - created).total_seconds()
            return elapsed > ttl
        except Exception:
            return False
    
    def read(self, key: str) -> Optional[dict[str, Any]]:
        """Read an entry from the blackboard."""
        entry = self.cache.get(key)
        if entry is None:
            return None
        
        if self._is_expired(entry):
            del self.cache[key]
            self._persist_to_disk()
            return None
        
        return entry
    
    def write(self, key: str, value: Any, source_agent: str = "unknown", 
              ttl: Optional[int] = None) -> dict[str, Any]:
        """Write an entry to the blackboard (with file locking)."""
        entry: dict[str, Any] = {
            "key": key,
            "value": value,
            "source_agent": source_agent,
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "ttl": ttl,
        }
        
        with blackboard_lock():
            # Reload to get latest state
            self._load_from_disk()
            self.cache[key] = entry
            self._persist_to_disk()
        
        return entry
    
    def delete(self, key: str) -> bool:
        """Delete an entry from the blackboard (with file locking)."""
        with blackboard_lock():
            self._load_from_disk()
            if key in self.cache:
                del self.cache[key]
                self._persist_to_disk()
                return True
        return False
    
    # ========================================================================
    # ATOMIC COMMIT WORKFLOW: propose → validate → commit
    # ========================================================================

    @staticmethod
    def _sanitize_change_id(change_id: str) -> str:
        """
        Sanitize change_id to prevent path traversal attacks.
        Only allows alphanumeric characters, hyphens, underscores, and dots.
        Rejects any path separators or parent directory references.
        """
        if not change_id:
            raise ValueError("change_id must be a non-empty string")
        # Strip whitespace
        sanitized = change_id.strip()
        # Reject path separators and parent directory traversal
        if any(c in sanitized for c in ('/', '\\', '..')):
            raise ValueError(
                f"Invalid change_id '{change_id}': must not contain path separators or '..'"
            )
        # Only allow safe characters: alphanumeric, hyphen, underscore, dot
        if not re.match(r'^[a-zA-Z0-9_\-\.]+$', sanitized):
            raise ValueError(
                f"Invalid change_id '{change_id}': only alphanumeric, hyphen, underscore, and dot allowed"
            )
        return sanitized

    def _safe_pending_path(self, change_id: str, suffix: str = ".pending.json") -> Path:
        """Build a pending-file path and verify it stays inside pending_dir."""
        safe_id = self._sanitize_change_id(change_id)
        target = (self.pending_dir / f"{safe_id}{suffix}").resolve()
        if not str(target).startswith(str(self.pending_dir.resolve())):
            raise ValueError(f"Path traversal blocked for change_id '{change_id}'")
        return target

    def propose_change(self, change_id: str, key: str, value: Any, 
                       source_agent: str = "unknown", ttl: Optional[int] = None,
                       operation: str = "write") -> dict[str, Any]:
        """
        Stage a change without applying it (Step 1 of atomic commit).
        
        The change is written to a .pending file and must be validated
        and committed by the orchestrator before it takes effect.
        """
        pending_file = self._safe_pending_path(change_id)
        
        # Check for duplicate change_id
        if pending_file.exists():
            return {
                "success": False,
                "error": f"Change ID '{change_id}' already exists. Use a unique ID."
            }
        
        # Get current value for conflict detection
        current_entry = self.cache.get(key)
        current_hash = None
        if current_entry:
            current_hash = hashlib.sha256(
                json.dumps(current_entry, sort_keys=True).encode()
            ).hexdigest()[:16]
        
        change_set: dict[str, Any] = {
            "change_id": change_id,
            "operation": operation,  # "write" or "delete"
            "key": key,
            "value": value,
            "source_agent": source_agent,
            "ttl": ttl,
            "proposed_at": datetime.now(timezone.utc).isoformat(),
            "status": "pending",
            "base_hash": current_hash,  # For conflict detection
        }
        
        pending_file.write_text(json.dumps(change_set, indent=2))
        
        return {
            "success": True,
            "change_id": change_id,
            "status": "proposed",
            "pending_file": str(pending_file),
            "message": "Change staged. Run 'validate' then 'commit' to apply."
        }
    
    def validate_change(self, change_id: str) -> dict[str, Any]:
        """
        Validate a pending change for conflicts (Step 2 of atomic commit).
        
        Checks:
        - Change exists
        - No conflicting changes to the same key
        - Base hash matches (data hasn't changed since proposal)
        """
        pending_file = self._safe_pending_path(change_id)
        
        if not pending_file.exists():
            return {
                "valid": False,
                "error": f"Change '{change_id}' not found. Was it proposed?"
            }
        
        change_set = json.loads(pending_file.read_text())
        
        if change_set["status"] != "pending":
            return {
                "valid": False,
                "error": f"Change is in '{change_set['status']}' state, not 'pending'"
            }
        
        key = change_set["key"]
        base_hash = change_set["base_hash"]
        
        # Check for conflicts: has the key changed since we proposed?
        with blackboard_lock():
            self._load_from_disk()
            current_entry = self.cache.get(key)
        
        current_hash = None
        if current_entry:
            current_hash = hashlib.sha256(
                json.dumps(current_entry, sort_keys=True).encode()
            ).hexdigest()[:16]
        
        if base_hash != current_hash:
            return {
                "valid": False,
                "conflict": True,
                "error": f"CONFLICT: Key '{key}' was modified since proposal. "
                         f"Expected hash {base_hash}, got {current_hash}. "
                         "Abort and re-propose with fresh data.",
                "current_value": current_entry
            }
        
        # Check for other pending changes to the same key
        conflicts: list[str] = []
        for other_file in self.pending_dir.glob("*.pending.json"):
            if other_file.name == pending_file.name:
                continue
            other_change = json.loads(other_file.read_text())
            if other_change["key"] == key and other_change["status"] == "pending":
                conflicts.append(other_change["change_id"])
        
        if conflicts:
            return {
                "valid": False,
                "conflict": True,
                "error": f"CONFLICT: Other pending changes affect key '{key}': {conflicts}. "
                         "Resolve conflicts before committing."
            }
        
        # Mark as validated
        change_set["status"] = "validated"
        change_set["validated_at"] = datetime.now(timezone.utc).isoformat()
        pending_file.write_text(json.dumps(change_set, indent=2))
        
        return {
            "valid": True,
            "change_id": change_id,
            "key": key,
            "status": "validated",
            "message": "No conflicts detected. Ready to commit."
        }
    
    def commit_change(self, change_id: str) -> dict[str, Any]:
        """
        Apply a validated change atomically (Step 3 of atomic commit).
        """
        pending_file = self._safe_pending_path(change_id)
        
        if not pending_file.exists():
            return {
                "committed": False,
                "error": f"Change '{change_id}' not found."
            }
        
        change_set = json.loads(pending_file.read_text())
        
        if change_set["status"] != "validated":
            return {
                "committed": False,
                "error": f"Change must be validated first. Current status: {change_set['status']}"
            }
        
        # Apply the change atomically
        with blackboard_lock():
            self._load_from_disk()
            
            if change_set["operation"] == "delete":
                if change_set["key"] in self.cache:
                    del self.cache[change_set["key"]]
            else:
                entry: dict[str, Any] = {
                    "key": change_set["key"],
                    "value": change_set["value"],
                    "source_agent": change_set["source_agent"],
                    "timestamp": datetime.now(timezone.utc).isoformat(),
                    "ttl": change_set["ttl"],
                    "committed_from": change_id
                }
                self.cache[change_set["key"]] = entry
            
            self._persist_to_disk()
        
        # Archive the committed change
        change_set["status"] = "committed"
        change_set["committed_at"] = datetime.now(timezone.utc).isoformat()
        
        safe_id = self._sanitize_change_id(change_id)
        archive_dir = self.pending_dir / "archive"
        archive_dir.mkdir(exist_ok=True)
        archive_file = archive_dir / f"{safe_id}.committed.json"
        archive_file.write_text(json.dumps(change_set, indent=2))
        
        # Remove pending file
        pending_file.unlink()
        
        return {
            "committed": True,
            "change_id": change_id,
            "key": change_set["key"],
            "operation": change_set["operation"],
            "message": "Change committed atomically."
        }
    
    def abort_change(self, change_id: str) -> dict[str, Any]:
        """Abort a pending change without applying it."""
        pending_file = self._safe_pending_path(change_id)
        
        if not pending_file.exists():
            return {
                "aborted": False,
                "error": f"Change '{change_id}' not found."
            }
        
        change_set = json.loads(pending_file.read_text())
        change_set["status"] = "aborted"
        change_set["aborted_at"] = datetime.now(timezone.utc).isoformat()
        
        # Archive the aborted change
        safe_id = self._sanitize_change_id(change_id)
        archive_dir = self.pending_dir / "archive"
        archive_dir.mkdir(exist_ok=True)
        archive_file = archive_dir / f"{safe_id}.aborted.json"
        archive_file.write_text(json.dumps(change_set, indent=2))
        
        pending_file.unlink()
        
        return {
            "aborted": True,
            "change_id": change_id,
            "key": change_set["key"]
        }
    
    def list_pending_changes(self) -> list[dict[str, Any]]:
        """List all pending changes awaiting commit."""
        pending: list[dict[str, Any]] = []
        for pending_file in self.pending_dir.glob("*.pending.json"):
            change_set = json.loads(pending_file.read_text())
            pending.append({
                "change_id": change_set["change_id"],
                "key": change_set["key"],
                "operation": change_set["operation"],
                "source_agent": change_set["source_agent"],
                "status": change_set["status"],
                "proposed_at": change_set["proposed_at"]
            })
        return pending
    
    def exists(self, key: str) -> bool:
        """Check if a key exists (and is not expired)."""
        return self.read(key) is not None
    
    def list_keys(self) -> list[str]:
        """List all valid (non-expired) keys."""
        valid_keys: list[str] = []
        for key in list(self.cache.keys()):
            if self.read(key) is not None:
                valid_keys.append(key)
        return valid_keys
    
    def get_snapshot(self) -> dict[str, dict[str, Any]]:
        """Get a snapshot of all valid entries."""
        snapshot: dict[str, dict[str, Any]] = {}
        for key in list(self.cache.keys()):
            entry = self.read(key)
            if entry is not None:
                snapshot[key] = entry
        return snapshot


def main():
    parser = argparse.ArgumentParser(
        description="Shared Blackboard - Agent Coordination State Manager (Atomic Commit Edition)",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Commands:
  write KEY VALUE [--ttl SECONDS]     Write a value (with file locking)
  read KEY                            Read a value
  delete KEY                          Delete a key
  list                                List all keys
  snapshot                            Get full snapshot as JSON

Atomic Commit Workflow (for multi-agent safety):
  propose CHANGE_ID KEY VALUE         Stage a change (Step 1)
  validate CHANGE_ID                  Check for conflicts (Step 2)
  commit CHANGE_ID                    Apply atomically (Step 3)
  abort CHANGE_ID                     Cancel a pending change
  list-pending                        Show all pending changes

Examples:
  %(prog)s write "task:analysis" '{"status": "running"}'
  %(prog)s write "cache:temp" '{"data": [1,2,3]}' --ttl 3600
  
  # Safe multi-agent update:
  %(prog)s propose "chg_001" "order:123" '{"status": "approved"}'
  %(prog)s validate "chg_001"
  %(prog)s commit "chg_001"
"""
    )
    
    parser.add_argument(
        "command",
        choices=["write", "read", "delete", "list", "snapshot", 
                 "propose", "validate", "commit", "abort", "list-pending"],
        help="Command to execute"
    )
    parser.add_argument(
        "key",
        nargs="?",
        help="Key name or Change ID (depending on command)"
    )
    parser.add_argument(
        "value",
        nargs="?",
        help="JSON value (required for write/propose)"
    )
    parser.add_argument(
        "--ttl",
        type=int,
        help="Time-to-live in seconds (for write/propose)"
    )
    parser.add_argument(
        "--agent",
        default="cli",
        help="Source agent ID (for write/propose)"
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help="Output as JSON"
    )
    parser.add_argument(
        "--path",
        type=Path,
        default=BLACKBOARD_PATH,
        help="Path to blackboard file (must be inside the project directory)",
    )
    parser.add_argument(
        "--env",
        default="",
        help="Target environment (dev|st|sit|qa|sandbox|preprod|prod). Overrides NETWORK_AI_ENV."
    )
    
    args = parser.parse_args()
    # Re-resolve data dir if --env was provided explicitly
    if args.env:
        _data = _resolve_data_dir(args.env)
        args.path = _data / "swarm-blackboard.md"

    # Validate --path against the project root to prevent path traversal (CWE-22).
    # Resolving symlinks before comparison ensures traversal via symlinks is also blocked.
    _project_root = Path(__file__).parent.parent.resolve()
    try:
        args.path.resolve().relative_to(_project_root)
    except ValueError:
        print(
            f"Error: --path must be inside the project directory ({_project_root}). "
            f"Got: {args.path.resolve()}",
            file=sys.stderr,
        )
        sys.exit(1)

    bb = SharedBlackboard(args.path)
    
    try:
        if args.command == "write":
            if not args.key or not args.value:
                print("Error: write requires KEY and VALUE", file=sys.stderr)
                sys.exit(1)
            
            try:
                value = json.loads(args.value)
            except json.JSONDecodeError:
                value = args.value
            
            entry = bb.write(args.key, value, args.agent, args.ttl)
            
            if args.json:
                print(json.dumps(entry, indent=2))
            else:
                print(f"✅ Written: {args.key} (with lock)")
                if args.ttl:
                    print(f"   TTL: {args.ttl} seconds")
        
        elif args.command == "read":
            if not args.key:
                print("Error: read requires KEY", file=sys.stderr)
                sys.exit(1)
            
            entry = bb.read(args.key)
            
            if entry is None:
                if args.json:
                    print("null")
                else:
                    print(f"❌ Key not found or expired: {args.key}")
                sys.exit(1)
            
            if args.json:
                print(json.dumps(entry, indent=2))
            else:
                print(f"📖 {args.key}:")
                print(f"   Value: {json.dumps(entry.get('value'))}")
                print(f"   Source: {entry.get('source_agent')}")
                print(f"   Timestamp: {entry.get('timestamp')}")
                if entry.get('ttl'):
                    print(f"   TTL: {entry['ttl']} seconds")
        
        elif args.command == "delete":
            if not args.key:
                print("Error: delete requires KEY", file=sys.stderr)
                sys.exit(1)
            
            if bb.delete(args.key):
                print(f"✅ Deleted: {args.key}")
            else:
                print(f"❌ Key not found: {args.key}")
                sys.exit(1)
        
        elif args.command == "list":
            keys = bb.list_keys()
            
            if args.json:
                print(json.dumps(keys, indent=2))
            else:
                if keys:
                    print(f"📋 Blackboard keys ({len(keys)}):")
                    for key in sorted(keys):
                        entry = bb.read(key)
                        ttl_info = f" [TTL: {entry['ttl']}s]" if entry and entry.get('ttl') else ""
                        print(f"   • {key}{ttl_info}")
                else:
                    print("📋 Blackboard is empty")
        
        elif args.command == "snapshot":
            snapshot = bb.get_snapshot()
            print(json.dumps(snapshot, indent=2))
        
        # === ATOMIC COMMIT COMMANDS ===
        
        elif args.command == "propose":
            if not args.key or not args.value:
                print("Error: propose requires CHANGE_ID and KEY VALUE", file=sys.stderr)
                print("Usage: propose CHANGE_ID KEY VALUE", file=sys.stderr)
                sys.exit(1)
            
            # Parse: propose CHANGE_ID KEY VALUE (key is actually change_id, value is "KEY VALUE")
            parts = args.value.split(" ", 1)
            if len(parts) < 2:
                print("Error: propose requires CHANGE_ID KEY VALUE", file=sys.stderr)
                sys.exit(1)
            
            change_id = args.key
            actual_key = parts[0]
            actual_value_str = parts[1] if len(parts) > 1 else "{}"
            
            try:
                actual_value = json.loads(actual_value_str)
            except json.JSONDecodeError:
                actual_value = actual_value_str
            
            result = bb.propose_change(change_id, actual_key, actual_value, args.agent, args.ttl)
            
            if args.json:
                print(json.dumps(result, indent=2))
            else:
                if result["success"]:
                    print(f"📝 Change PROPOSED: {change_id}")
                    print(f"   Key: {actual_key}")
                    print(f"   Status: pending validation")
                    print(f"   Next: run 'validate {change_id}'")
                else:
                    print(f"❌ Proposal FAILED: {result['error']}")
                    sys.exit(1)
        
        elif args.command == "validate":
            if not args.key:
                print("Error: validate requires CHANGE_ID", file=sys.stderr)
                sys.exit(1)
            
            result = bb.validate_change(args.key)
            
            if args.json:
                print(json.dumps(result, indent=2))
            else:
                if result["valid"]:
                    print(f"✅ Change VALIDATED: {args.key}")
                    print(f"   Key: {result['key']}")
                    print(f"   No conflicts detected")
                    print(f"   Next: run 'commit {args.key}'")
                else:
                    print(f"❌ Validation FAILED: {result['error']}")
                    sys.exit(1)
        
        elif args.command == "commit":
            if not args.key:
                print("Error: commit requires CHANGE_ID", file=sys.stderr)
                sys.exit(1)
            
            result = bb.commit_change(args.key)
            
            if args.json:
                print(json.dumps(result, indent=2))
            else:
                if result["committed"]:
                    print(f"🎉 Change COMMITTED: {args.key}")
                    print(f"   Key: {result['key']}")
                    print(f"   Operation: {result['operation']}")
                else:
                    print(f"❌ Commit FAILED: {result['error']}")
                    sys.exit(1)
        
        elif args.command == "abort":
            if not args.key:
                print("Error: abort requires CHANGE_ID", file=sys.stderr)
                sys.exit(1)
            
            result = bb.abort_change(args.key)
            
            if args.json:
                print(json.dumps(result, indent=2))
            else:
                if result["aborted"]:
                    print(f"🚫 Change ABORTED: {args.key}")
                else:
                    print(f"❌ Abort FAILED: {result['error']}")
                    sys.exit(1)
        
        elif args.command == "list-pending":
            pending = bb.list_pending_changes()
            
            if args.json:
                print(json.dumps(pending, indent=2))
            else:
                if pending:
                    print(f"📋 Pending changes ({len(pending)}):")
                    for p in pending:
                        status_icon = "🟡" if p["status"] == "pending" else "🟢"
                        print(f"   {status_icon} {p['change_id']}: {p['operation']} '{p['key']}'")
                        print(f"      Agent: {p['source_agent']} | Status: {p['status']}")
                else:
                    print("📋 No pending changes")
    
    except TimeoutError as e:
        print(f"🔒 LOCK TIMEOUT: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/check_permission.py
content: #!/usr/bin/env python3
# SECURITY: This script makes NO network calls and spawns NO subprocesses.
# All I/O is local file operations only:
#   READS:  data[/<env>]/active_grants.json, data[/<env>]/audit_log.jsonl,
#           data[/<env>]/.signing_key (HMAC key; auto-created on first run)
#   WRITES: data[/<env>]/active_grants.json, data[/<env>]/audit_log.jsonl,
#           data[/<env>]/.signing_key (on first run only; chmod 0o600)
# Imports used: argparse, json, re, sys, uuid, hmac, hashlib, datetime, pathlib, typing
# No imports of: requests, socket, subprocess, urllib, http, ssl, ftplib, smtplib
#
# SECURITY: Justification strings supplied via --justification are truncated to
# _JUSTIFICATION_MAX_LOG_LEN characters before being written to audit_log.jsonl.
# Do NOT include PII, credentials, secret names, API keys, or sensitive business
# data in justification fields. Audit summary output (--audit-summary --json)
# omits justification text entirely from returned entries. Grant tokens are only
# shown at issuance time; listing commands (--active-grants) always mask tokens
# to a short prefix.
"""
AuthGuardian Permission Checker

Evaluates permission requests for accessing sensitive resources
(DATABASE, PAYMENTS, EMAIL, FILE_EXPORT).

Usage:
    python check_permission.py --agent AGENT_ID --resource RESOURCE_TYPE \
        --justification "REASON" [--scope SCOPE]
    python check_permission.py --active-grants [--agent AGENT_ID] [--json]
    python check_permission.py --audit-summary [--last N] [--json]

Examples:
    python check_permission.py --agent data_analyst --resource DATABASE \
        --justification "Need customer order history for sales report" \
        --scope "read:orders"

    python check_permission.py --active-grants
    python check_permission.py --active-grants --agent data_analyst --json
    python check_permission.py --audit-summary --last 20 --json
"""

import argparse
import hashlib
import hmac
import json
import re
import sys
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
import os

# Configuration
GRANT_TOKEN_TTL_MINUTES = 5

def _resolve_data_dir(env: str = "") -> "Path":
    """Return the active data directory, scoped to <env> when set."""
    import re as _re
    _env = env or os.environ.get("NETWORK_AI_ENV", "")
    base = Path(__file__).parent.parent / "data"
    if _env:
        if not _re.match(r'^[a-zA-Z0-9_-]+$', _env):
            raise ValueError(f"Invalid NETWORK_AI_ENV value: {_env!r}")
        return base / _env
    return base

_DATA_DIR = _resolve_data_dir()
GRANTS_FILE = _DATA_DIR / "active_grants.json"
AUDIT_LOG = _DATA_DIR / "audit_log.jsonl"

# ADVISORY TOKENS — IMPORTANT
# Grant tokens produced by this script are ADVISORY scoring outputs only.
# They are NOT authenticated credentials. The agent_id supplied via --agent
# is accepted as-is from the caller and is NOT verified against an identity
# provider. Downstream systems MUST treat these tokens as hints, not proof
# of identity, and SHOULD require a separate authenticated session or human
# approval before honouring access to sensitive resources.
#
# For PAYMENTS, DATABASE (write), and FILE_EXPORT the caller must also pass
# --confirm-high-risk to acknowledge the advisory-only nature of the grant.

# Default trust levels for known agents
DEFAULT_TRUST_LEVELS = {
    "orchestrator": 0.9,
    "data_analyst": 0.8,
    "strategy_advisor": 0.7,
    "risk_assessor": 0.85,
}

# Agents whose identity is pre-registered. Any agent_id NOT in this set
# receives a reduced trust score (0.3) and triggers an advisory warning.
KNOWN_AGENTS: set[str] = set(DEFAULT_TRUST_LEVELS.keys())

# Maximum characters of a justification string stored in the audit log.
# Content beyond this limit is dropped before writing to prevent unbounded
# sensitive-data retention (SkillSpector Ssd3).
_JUSTIFICATION_MAX_LOG_LEN: int = 200

# Resource types that require --confirm-high-risk before a grant is issued
HIGH_RISK_RESOURCES: set[str] = {"PAYMENTS", "DATABASE"}

# Base risk scores for resource types
BASE_RISKS = {
    "DATABASE": 0.5,      # Internal database access
    "PAYMENTS": 0.7,      # Payment/financial systems
    "EMAIL": 0.4,         # Email sending capability
    "FILE_EXPORT": 0.6,   # Exporting data to files
}

# Default restrictions by resource type
RESTRICTIONS = {
    "DATABASE": ["read_only", "max_records:100"],
    "PAYMENTS": ["read_only", "no_pii_fields", "audit_required"],
    "EMAIL": ["rate_limit:10_per_minute"],
    "FILE_EXPORT": ["anonymize_pii", "local_only"],
}


def ensure_data_dir():
    """Ensure data directory exists."""
    data_dir = Path(__file__).parent.parent / "data"
    data_dir.mkdir(exist_ok=True)
    return data_dir


def detect_injection(justification: str) -> bool:
    """
    Detect prompt-injection and manipulation patterns in justifications.

    Returns True if the justification looks like a prompt-injection attempt.
    """
    injection_patterns = [
        r'ignore\s+(previous|above|prior|all)',
        r'override\s+(policy|restriction|rule|permission|security)',
        r'system\s*prompt',
        r'you\s+are\s+(now|a)',
        r'act\s+as\s+(if|a|an)',
        r'pretend\s+(to|that|you)',
        r'bypass\s+(security|check|restriction|auth)',
        r'grant\s+(me|access|permission)\s+(anyway|regardless|now)',
        r'disregard\s+(policy|rule|restriction|previous)',
        r'admin\s+(mode|access|override)',
        r'sudo\b',
        r'jailbreak',
        r'do\s+not\s+(check|verify|validate|restrict)',
        r'skip\s+(validation|verification|check)',
        r'trust\s+level\s*[:=]',
        r'score\s*[:=]+\s*[\d.]',
    ]
    text = justification.lower()
    for pattern in injection_patterns:
        if re.search(pattern, text):
            return True
    return False


def score_justification(justification: str) -> float:
    """
    Score the quality of a justification with hardened validation.

    Defenses against prompt injection and keyword stuffing:
    - Injection pattern detection (immediate reject)
    - Maximum length cap (prevents obfuscation in long text)
    - Keyword-stuffing detection (penalises unnatural keyword density)
    - Unique-word ratio check (catches copy-paste padding)
    - Structural coherence (requires natural sentence structure)

    Criteria (after safety checks):
    - Length (more detail = better, but capped)
    - Contains task-related keywords (capped contribution)
    - Contains specificity keywords (capped contribution)
    - No test/debug keywords
    - Structural coherence bonus
    """
    # ----- Hard reject: injection patterns -----
    if detect_injection(justification):
        return 0.0

    # ----- Hard reject: empty or whitespace-only -----
    stripped = justification.strip()
    if not stripped:
        return 0.0

    # ----- Hard cap: excessively long justifications are suspicious -----
    MAX_JUSTIFICATION_LENGTH = 500
    if len(stripped) > MAX_JUSTIFICATION_LENGTH:
        return 0.1  # Suspiciously long — allow re-submission with concise text

    words = stripped.split()
    word_count = len(words)

    # ----- Hard reject: too few words to be meaningful -----
    if word_count < 3:
        return 0.1

    # ----- Repetition / padding detection -----
    unique_words = set(w.lower() for w in words)
    unique_ratio = len(unique_words) / word_count  # word_count >= 3 guaranteed above
    if unique_ratio < 0.4:
        return 0.1  # More than 60% repeated words — likely padding

    # ----- Keyword-stuffing detection -----
    task_keywords = re.findall(
        r'\b(task|purpose|need|require|generate|analyze|create|process)\b',
        stripped, re.IGNORECASE,
    )
    specificity_keywords = re.findall(
        r'\b(specific|particular|exact|quarterly|annual|report|summary)\b',
        stripped, re.IGNORECASE,
    )
    total_matched = len(task_keywords) + len(specificity_keywords)
    keyword_density = total_matched / word_count  # word_count >= 3 guaranteed above
    if keyword_density > 0.5:
        return 0.1  # More than half the words are scoring keywords — stuffing

    # ----- Scoring (defensive caps per category) -----
    score = 0.0

    # Length contribution (max 0.25)
    if len(stripped) > 20:
        score += 0.15
    if len(stripped) > 50:
        score += 0.10

    # Task keyword presence (max 0.20, but only first match counts)
    if task_keywords:
        score += 0.20

    # Specificity keyword presence (max 0.20, but only first match counts)
    if specificity_keywords:
        score += 0.20

    # No test/debug markers (max 0.15)
    if not re.search(r'\b(test|debug|try|experiment)\b', stripped, re.IGNORECASE):
        score += 0.15

    # Structural coherence: sentence-like structure (max 0.20)
    # Must contain at least one verb-like pattern and read like prose
    has_verb = bool(re.search(
        r'\b(is|are|was|were|need|needs|require|requires|must|should|will|'
        r'generate|generating|analyze|analyzing|create|creating|process|processing|'
        r'prepare|preparing|compile|compiling|review|reviewing|access|accessing|'
        r'retrieve|retrieving|export|exporting|send|sending|run|running)\b',
        stripped, re.IGNORECASE,
    ))
    has_noun_object = bool(re.search(
        r'\b(data|report|records|invoices?|orders?|customers?|accounts?|'
        r'transactions?|files?|emails?|results?|metrics?|statistics?|'
        r'analysis|documents?|exports?|payments?|entries|logs?|summaries)\b',
        stripped, re.IGNORECASE,
    ))
    if has_verb and has_noun_object:
        score += 0.20

    return min(score, 1.0)


def assess_risk(resource_type: str, scope: Optional[str] = None) -> float:
    """
    Assess the risk level of a permission request.
    
    Factors:
    - Base risk of resource type
    - Scope breadth (broad scopes = higher risk)
    - Write operations (higher risk)
    """
    risk = BASE_RISKS.get(resource_type, 0.5)
    
    # Broad scopes increase risk
    if not scope or scope in ("*", "all"):
        risk += 0.2
    
    # Write operations increase risk
    if scope and re.search(r'\b(write|delete|update|modify|create)\b', scope, re.IGNORECASE):
        risk += 0.2
    
    return min(risk, 1.0)


def generate_grant_token() -> str:
    """Generate a unique grant token."""
    return f"grant_{uuid.uuid4().hex}"


def _load_signing_key() -> bytes:
    """
    Load or create the local HMAC-SHA256 signing key for grant tokens.

    The key is stored at data[/<env>]/.signing_key as a 32-byte hex string.
    It is auto-generated with os.urandom(32) on first use and restricted to
    mode 0o600.  It lives inside the env-scoped data dir so multi-environment
    deployments each have an independent key.
    """
    key_file = _DATA_DIR / ".signing_key"
    if key_file.exists():
        try:
            return bytes.fromhex(key_file.read_text().strip())
        except (ValueError, OSError):
            pass  # Corrupt key file — regenerate below
    key = os.urandom(32)
    _DATA_DIR.mkdir(parents=True, exist_ok=True)
    key_file.write_text(key.hex())
    try:
        key_file.chmod(0o600)  # Restrict to owner; no-op on Windows
    except OSError:
        # chmod is not supported on all platforms (e.g. Windows NTFS);
        # the key is still functional — caller must protect the data directory.
        pass
    return key


def _sign_grant(grant: dict[str, Any]) -> str:
    """
    Return an HMAC-SHA256 hex signature over the canonical grant fields.

    Covered fields: token, agent_id, resource_type, scope, expires_at, granted_at.
    Storing this in the grant record lets validate_token.py detect any
    post-issuance tampering of active_grants.json.
    """
    payload = "|".join([
        grant.get("token", ""),
        grant.get("agent_id", ""),
        grant.get("resource_type", ""),
        grant.get("scope") or "",
        grant.get("expires_at", ""),
        grant.get("granted_at", ""),
    ])
    key = _load_signing_key()
    return hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()


def log_audit(action: str, details: dict[str, Any]) -> None:
    """Append entry to audit log."""
    ensure_data_dir()
    entry: dict[str, Any] = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "action": action,
        "details": details
    }
    with open(AUDIT_LOG, "a") as f:
        f.write(json.dumps(entry) + "\n")


def save_grant(grant: dict[str, Any]) -> None:
    """Save grant to persistent storage."""
    ensure_data_dir()
    grants = {}
    if GRANTS_FILE.exists():
        try:
            grants = json.loads(GRANTS_FILE.read_text())
        except json.JSONDecodeError:
            grants = {}
    
    grants[grant["token"]] = grant
    GRANTS_FILE.write_text(json.dumps(grants, indent=2))


def evaluate_permission(
    agent_id: str,
    resource_type: str,
    justification: str,
    scope: Optional[str] = None,
    confirm_high_risk: bool = False,
) -> dict[str, Any]:
    """
    Evaluate a permission request using weighted scoring.

    NOTE: These tokens are ADVISORY ONLY — the caller supplies their own
    agent_id and it is not authenticated. Downstream systems must not treat
    the resulting token as proof of identity without additional verification.

    Weights:
    - Justification Quality: 40%
    - Agent Trust Level: 30%
    - Risk Assessment: 30%
    """
    # Warn if agent_id is not in the pre-registered known-agents list
    unknown_agent = agent_id not in KNOWN_AGENTS

    # Log the request — justification is truncated before writing to limit
    # sensitive-data retention in audit_log.jsonl.
    _justification_log = (
        justification[:_JUSTIFICATION_MAX_LOG_LEN] + " [truncated]"
        if len(justification) > _JUSTIFICATION_MAX_LOG_LEN
        else justification
    )
    log_audit("permission_request", {
        "agent_id": agent_id,
        "resource_type": resource_type,
        "justification": _justification_log,
        "scope": scope,
        "unknown_agent": unknown_agent,
    })

    # Require explicit acknowledgement for high-risk resources
    needs_confirmation = (
        resource_type in HIGH_RISK_RESOURCES
        or (resource_type == "DATABASE" and scope and re.search(
            r'\b(write|delete|update|modify|create)\b', scope, re.IGNORECASE
        ))
    )
    if needs_confirmation and not confirm_high_risk:
        return {
            "granted": False,
            "advisory": True,
            "reason": (
                f"{resource_type} is a high-risk resource. Re-run with --confirm-high-risk "
                "to acknowledge that this token is advisory only and does not authenticate "
                "the supplied agent identity."
            ),
            "scores": {"justification": None, "trust": None, "risk": None},
        }

    # 1. Justification Quality (40% weight)
    justification_score = score_justification(justification)
    if justification_score < 0.3:
        return {
            "granted": False,
            "advisory": True,
            "reason": "Justification is insufficient. Please provide specific task context.",
            "scores": {
                "justification": justification_score,
                "trust": None,
                "risk": None,
            },
        }

    # 2. Agent Trust Level (30% weight)
    # Unknown agents receive a reduced base trust score (0.3) — their identity
    # has not been pre-registered and cannot be verified by this script.
    trust_level = DEFAULT_TRUST_LEVELS.get(agent_id, 0.3) if not unknown_agent \
        else 0.3
    if trust_level < 0.4:
        return {
            "granted": False,
            "advisory": True,
            "reason": (
                "Agent trust level is below threshold. Escalate to human operator."
                + (" (unrecognized agent_id — identity not pre-registered)" if unknown_agent else "")
            ),
            "scores": {
                "justification": justification_score,
                "trust": trust_level,
                "risk": None,
            },
        }
    
    # 3. Risk Assessment (30% weight)
    risk_score = assess_risk(resource_type, scope)
    if risk_score > 0.8:
        return {
            "granted": False,
            "advisory": True,
            "reason": "Risk assessment exceeds acceptable threshold. Narrow the requested scope.",
            "scores": {
                "justification": justification_score,
                "trust": trust_level,
                "risk": risk_score,
            },
        }
    
    # Calculate weighted approval score
    weighted_score = (
        justification_score * 0.4 +
        trust_level * 0.3 +
        (1 - risk_score) * 0.3
    )
    
    if weighted_score < 0.5:
        return {
            "granted": False,
            "advisory": True,
            "reason": f"Combined evaluation score ({weighted_score:.2f}) below threshold (0.5).",
            "scores": {
                "justification": justification_score,
                "trust": trust_level,
                "risk": risk_score,
                "weighted": weighted_score,
            },
        }
    
    # Generate grant
    [REDACTED_SECRET])
    expires_at = (datetime.now(timezone.utc) + timedelta(minutes=GRANT_TOKEN_TTL_MINUTES)).isoformat()
    restrictions = RESTRICTIONS.get(resource_type, [])

    grant: dict[str, Any] = {
        "token": token,
        "agent_id": agent_id,
        "resource_type": resource_type,
        "scope": scope,
        "expires_at": expires_at,
        "restrictions": restrictions,
        "granted_at": datetime.now(timezone.utc).isoformat(),
        "advisory": True,           # Always advisory — not an authenticated credential
        "unknown_agent": unknown_agent,
    }

    # Sign the grant — lets validate_token.py detect tampered grant records
    grant["_sig"] = _sign_grant(grant)

    # Save grant and log
    save_grant(grant)
    log_audit("permission_granted", grant)

    return {
        "granted": True,
        "advisory": True,           # Token is advisory — agent identity was not verified
        "unknown_agent": unknown_agent,
        "token": token,
        "expires_at": expires_at,
        "restrictions": restrictions,
        "scores": {
            "justification": justification_score,
            "trust": trust_level,
            "risk": risk_score,
            "weighted": weighted_score,
        },
        "notice": (
            "This token was issued based on local scoring only. "
            "The agent identity supplied via --agent was NOT cryptographically verified. "
            "Treat this token as advisory and require human approval before granting "
            "access to sensitive systems."
        ),
    }


def list_active_grants(agent_filter: Optional[str] = None, as_json: bool = False) -> int:
    """
    Show which agents currently hold access to which APIs with expiry times.

    Reads data/active_grants.json, filters out expired grants,
    and displays remaining grants with TTL.
    """
    if not GRANTS_FILE.exists():
        if as_json:
            print(json.dumps({"grants": [], "total": 0, "expired_cleaned": 0}))
        else:
            print("No active grants. (No grants file found.)")
        return 0

    try:
        grants = json.loads(GRANTS_FILE.read_text())
    except (json.JSONDecodeError, OSError):
        if as_json:
            print(json.dumps({"error": "Could not read grants file"}))
        else:
            print("Error: Could not read grants file.")
        return 1

    now = datetime.now(timezone.utc)
    active: list[dict[str, Any]] = []
    expired_count = 0

    for token, grant in grants.items():
        try:
            expires_at = datetime.fromisoformat(grant["expires_at"])
        except (KeyError, ValueError):
            expired_count += 1
            continue

        if expires_at <= now:
            expired_count += 1
            continue

        if agent_filter and grant.get("agent_id") != agent_filter:
            continue

        remaining = expires_at - now
        minutes_left = remaining.total_seconds() / 60

        active.append({
            "token": token[:16] + "..." if len(token) > 16 else token,
            "agent_id": grant.get("agent_id", "unknown"),
            "resource_type": grant.get("resource_type", "unknown"),
            "scope": grant.get("scope"),
            "granted_at": grant.get("granted_at", "unknown"),
            "expires_at": grant["expires_at"],
            "minutes_remaining": round(minutes_left, 1),
            "restrictions": grant.get("restrictions", []),
        })

    # Sort by expiry (soonest first)
    active.sort(key=lambda g: g["expires_at"])

    if as_json:
        # Token values are masked (prefix only) — never emit full live tokens in output.
        output: dict[str, Any] = {
            "grants": active,
            "total": len(active),
            "expired_cleaned": expired_count,
        }
        print(json.dumps(output, indent=2))
    else:
        if not active:
            filter_msg = f" for agent '{agent_filter}'" if agent_filter else ""
            print(f"No active grants{filter_msg}. ({expired_count} expired.)")
        else:
            filter_msg = f" (agent: {agent_filter})" if agent_filter else ""
            print(f"Active Grants{filter_msg}:")
            print(f"{'='*70}")
            for g in active:
                print(f"  Agent:       {g['agent_id']}")
                print(f"  Resource:    {g['resource_type']}")
                if g["scope"]:
                    print(f"  Scope:       {g['scope']}")
                print(f"  Token:       {g['token']}")
                print(f"  Granted:     {g['granted_at']}")
                print(f"  Expires:     {g['expires_at']}")
                print(f"  Remaining:   {g['minutes_remaining']} min")
                if g["restrictions"]:
                    print(f"  Restrictions: {', '.join(g['restrictions'])}")
                print(f"  {'-'*66}")
            print(f"\nTotal: {len(active)} active, {expired_count} expired")

    return 0


def audit_summary(last_n: int = 20, as_json: bool = False) -> int:
    """
    Summarize recent permission requests, grants, and denials.

    Parses data/audit_log.jsonl and produces per-agent and per-resource
    breakdowns plus recent activity.
    """
    if not AUDIT_LOG.exists():
        if as_json:
            print(json.dumps({"entries": 0, "summary": {}, "recent": []}))
        else:
            print("No audit log found. (No permission requests recorded yet.)")
        return 0

    entries: list[dict[str, Any]] = []
    try:
        with open(AUDIT_LOG, "r") as f:
            for line in f:
                line = line.strip()
                if line:
                    try:
                        entries.append(json.loads(line))
                    except json.JSONDecodeError:
                        continue
    except OSError:
        if as_json:
            print(json.dumps({"error": "Could not read audit log"}))
        else:
            print("Error: Could not read audit log.")
        return 1

    if not entries:
        if as_json:
            print(json.dumps({"entries": 0, "summary": {}, "recent": []}))
        else:
            print("Audit log is empty.")
        return 0

    # Aggregate stats
    total_requests = 0
    total_grants = 0
    by_agent: dict[str, dict[str, int]] = {}
    by_resource: dict[str, dict[str, int]] = {}

    for entry in entries:
        action = entry.get("action", "")
        details = entry.get("details", {})
        agent_id = details.get("agent_id", "unknown")
        resource_type = details.get("resource_type", "unknown")

        if action == "permission_request":
            total_requests += 1
            by_agent.setdefault(agent_id, {"requests": 0, "grants": 0})
            by_agent[agent_id]["requests"] += 1
            by_resource.setdefault(resource_type, {"requests": 0, "grants": 0})
            by_resource[resource_type]["requests"] += 1
        elif action == "permission_granted":
            total_grants += 1
            by_agent.setdefault(agent_id, {"requests": 0, "grants": 0})
            by_agent[agent_id]["grants"] += 1
            by_resource.setdefault(resource_type, {"requests": 0, "grants": 0})
            by_resource[resource_type]["grants"] += 1

    total_denials = total_requests - total_grants

    # Recent entries (last N)
    recent = entries[-last_n:]

    # Time range
    first_ts = entries[0].get("timestamp", "unknown")
    last_ts = entries[-1].get("timestamp", "unknown")

    if as_json:
        # Omit justification text from summary output to avoid re-exposing
        # sensitive content that was logged earlier (SkillSpector Ssd3).
        def _redact_entry(e: dict[str, Any]) -> dict[str, Any]:
            if "details" not in e or "justification" not in e["details"]:
                return e
            return {**e, "details": {k: v for k, v in e["details"].items() if k != "justification"}}

        output: dict[str, Any] = {
            "total_entries": len(entries),
            "total_requests": total_requests,
            "total_grants": total_grants,
            "total_denials": total_denials,
            "time_range": {"first": first_ts, "last": last_ts},
            "by_agent": by_agent,
            "by_resource": by_resource,
            "recent": [_redact_entry(e) for e in recent[-last_n:]],
        }
        print(json.dumps(output, indent=2))
    else:
        print("Audit Summary")
        print(f"{'='*70}")
        print(f"  Log entries:  {len(entries)}")
        print(f"  Time range:   {first_ts}")
        print(f"                {last_ts}")
        print(f"")
        print(f"  Requests:     {total_requests}")
        print(f"  Grants:       {total_grants}")
        print(f"  Denials:      {total_denials}")
        grant_rate = (total_grants / total_requests * 100) if total_requests > 0 else 0
        print(f"  Grant Rate:   {grant_rate:.0f}%")

        if by_agent:
            print(f"\n  By Agent:")
            print(f"  {'-'*50}")
            print(f"  {'Agent':<20} {'Requests':>10} {'Grants':>10} {'Denials':>10}")
            print(f"  {'-'*50}")
            for agent_id, stats in sorted(by_agent.items()):
                denials = stats["requests"] - stats["grants"]
                print(f"  {agent_id:<20} {stats['requests']:>10} {stats['grants']:>10} {denials:>10}")

        if by_resource:
            print(f"\n  By Resource:")
            print(f"  {'-'*50}")
            print(f"  {'Resource':<20} {'Requests':>10} {'Grants':>10} {'Denials':>10}")
            print(f"  {'-'*50}")
            for resource_type, stats in sorted(by_resource.items()):
                denials = stats["requests"] - stats["grants"]
                print(f"  {resource_type:<20} {stats['requests']:>10} {stats['grants']:>10} {denials:>10}")

        print(f"\n  Recent Activity (last {min(last_n, len(recent))}):")
        print(f"  {'-'*66}")
        for entry in recent:
            ts = entry.get("timestamp", "?")[:19]
            action = entry.get("action", "?")
            details = entry.get("details", {})
            agent_id = details.get("agent_id", "?")
            resource_type = details.get("resource_type", "?")
            symbol = "GRANT" if action == "permission_granted" else "REQ" if action == "permission_request" else action.upper()
            print(f"  {ts}  [{symbol:>5}]  {agent_id} -> {resource_type}")

    return 0


def main():
    parser = argparse.ArgumentParser(
        description="AuthGuardian Permission Checker",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  Check permission:
    %(prog)s --agent data_analyst --resource DATABASE \\
        --justification "Need Q4 invoice data for quarterly report"

  List active grants:
    %(prog)s --active-grants
    %(prog)s --active-grants --agent data_analyst --json

  View audit summary:
    %(prog)s --audit-summary
    %(prog)s --audit-summary --last 50 --json
"""
    )

    # Action flags
    parser.add_argument(
        "--active-grants",
        action="store_true",
        help="List all active (non-expired) permission grants"
    )
    parser.add_argument(
        "--audit-summary",
        action="store_true",
        help="Show audit log summary with per-agent and per-resource breakdowns"
    )
    parser.add_argument(
        "--last",
        type=int,
        default=20,
        help="Number of recent audit entries to show (default: 20)"
    )

    # Permission check args (required only for check mode)
    parser.add_argument(
        "--agent", "-a",
        help="Agent ID requesting permission (required for check; optional filter for --active-grants)"
    )
    parser.add_argument(
        "--resource", "-r",
        choices=["DATABASE", "PAYMENTS", "EMAIL", "FILE_EXPORT"],
        help="Resource type to access"
    )
    parser.add_argument(
        "--justification", "-j",
        help="Business justification for the request"
    )
    parser.add_argument(
        "--scope", "-s",
        help="Specific scope of access (e.g., 'read:invoices')"
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help="Output result as JSON"
    )
    parser.add_argument(
        "--confirm-high-risk",
        action="store_true",
        dest="confirm_high_risk",
        help=(
            "Required for PAYMENTS and DATABASE resources. Acknowledges that the issued "
            "token is advisory only and that the caller-supplied agent identity was not "
            "cryptographically verified."
        ),
    )
    parser.add_argument(
        "--env",
        default="",
        help="Target environment (dev|st|sit|qa|sandbox|preprod|prod). Overrides NETWORK_AI_ENV."
    )

    args = parser.parse_args()

    # Re-resolve data paths if --env was provided explicitly.
    # Use globals() to avoid Pyright reportConstantRedefinition on uppercase names.
    if args.env:
        _data = _resolve_data_dir(args.env)
        globals()['GRANTS_FILE'] = _data / "active_grants.json"
        globals()['AUDIT_LOG'] = _data / "audit_log.jsonl"

    # --- Action: --active-grants ---
    if args.active_grants:
        sys.exit(list_active_grants(agent_filter=args.agent, as_json=args.json))

    # --- Action: --audit-summary ---
    if args.audit_summary:
        sys.exit(audit_summary(last_n=args.last, as_json=args.json))

    # --- Default action: permission check ---
    if not args.agent:
        parser.error("--agent is required for permission checks")
    if not args.resource:
        parser.error("--resource is required for permission checks")
    if not args.justification:
        parser.error("--justification is required for permission checks")

    result = evaluate_permission(
        agent_id=args.agent,
        resource_type=args.resource,
        justification=args.justification,
        scope=args.scope,
        confirm_high_risk=getattr(args, "confirm_high_risk", False),
    )

    if args.json:
        print(json.dumps(result, indent=2))
    else:
        if result["granted"]:
            print("GRANTED  [ADVISORY — agent identity was NOT verified]")
            print(f"[REDACTED_SECRET]'token']}")
            print(f"Expires:      {result['expires_at']}")
            print(f"Restrictions: {', '.join(result['restrictions'])}")
            if result.get("unknown_agent"):
                print("WARNING: agent_id is not in the pre-registered known-agents list.")
            print(f"\nNOTICE: {result.get('notice', '')}")
        else:
            print("DENIED")
            print(f"Reason: {result['reason']}")

        print("\nEvaluation Scores:")
        scores = result["scores"]
        if scores.get("justification") is not None:
            print(f"  Justification: {scores['justification']:.2f}")
        if scores.get("trust") is not None:
            print(f"  Trust Level:   {scores['trust']:.2f}")
        if scores.get("risk") is not None:
            print(f"  Risk Score:    {scores['risk']:.2f}")
        if scores.get("weighted") is not None:
            print(f"  Weighted:      {scores['weighted']:.2f}")

    sys.exit(0 if result["granted"] else 1)


if __name__ == "__main__":
    main()


path: scripts/context_manager.py
content: #!/usr/bin/env python3
# SECURITY: This script makes NO network calls and spawns NO subprocesses.
# All I/O is local file operations only:
#   READS:  data/project-context.json, data/audit_log.jsonl
#   WRITES: data/project-context.json, data/audit_log.jsonl
# Imports used: argparse, json, sys, datetime, pathlib, typing
# No imports of: requests, socket, subprocess, urllib, http, ssl, ftplib, smtplib
"""
Project Context Manager - Persistent Layer-3 Memory for Agent Swarms

Maintains a JSON file that stores long-lived project context: goals, architecture
decisions, tech stack, milestones, and banned approaches. This context is injected
into every agent session so all agents share the same project-level awareness,
regardless of what's currently on the short-term blackboard.

THE 3-LAYER MEMORY MODEL
  Layer 1 — Agent context    : current task, immediate instructions (ephemeral, per-agent)
  Layer 2 — Blackboard       : task results, grants, coordination state (shared, TTL-scoped)
  Layer 3 — Project context  : architecture decisions, goals, stack, milestones (THIS FILE)

Usage:
    python context_manager.py init --name "MyProject" [--description "..."] [--version "1.0.0"]
    python context_manager.py show
    python context_manager.py inject [--force]
    python context_manager.py update --section decisions  --add '{"decision": "...", "rationale": "..."}'
    python context_manager.py update --section milestones --complete "task name"
    python context_manager.py update --section milestones --add '{"planned": "task name"}'
    python context_manager.py update --section stack     --set '{"language": "TypeScript"}'
    python context_manager.py update --section goals     --add "Ship v2.0 before Q3"
    python context_manager.py update --section banned    --add "Direct DB writes from agents"

Examples:
    python context_manager.py init --name "Network-AI" --description "Multi-agent swarm framework" --version "4.5.0"
    python context_manager.py update --section decisions --add '{"decision": "Use atomic blackboard commits", "rationale": "Prevent race conditions"}'
    python context_manager.py update --section milestones --complete "v4.4.3 ClawHub clean-scan"
    python context_manager.py inject                          # blocked if context has prompt-injection patterns
    python context_manager.py inject --force                  # override block (trusted/CI environments only)
"""

import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, cast

def _resolve_data_dir(env: str = "") -> Path:
    """Return the active data directory, scoped to <env> when set."""
    import re as _re, os as _os
    _env = env or _os.environ.get("NETWORK_AI_ENV", "")
    base = Path(__file__).parent.parent / "data"
    if _env:
        if not _re.match(r'^[a-zA-Z0-9_-]+$', _env):
            raise ValueError(f"Invalid NETWORK_AI_ENV value: {_env!r}")
        return base / _env
    return base

_DATA_DIR = _resolve_data_dir()
CONTEXT_PATH = _DATA_DIR / "project-context.json"
AUDIT_LOG_PATH = _DATA_DIR / "audit_log.jsonl"

EMPTY_CONTEXT: dict[str, Any] = {
    "project": {
        "name": "",
        "description": "",
        "version": ""
    },
    "goals": [],
    "stack": {},
    "milestones": {
        "completed": [],
        "in_progress": [],
        "planned": []
    },
    "decisions": [],
    "banned_approaches": [],
    "agents": {},
    "updated_at": ""
}


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _validate_context(ctx: dict[str, Any]) -> list[str]:
    """
    Validate the project context file against the expected schema.

    Returns a list of warning strings (empty = clean).
    Checks:
    - Required top-level keys are present
    - String fields are not excessively long (injection/poisoning guard)
    - List entries are strings or dicts, not executable-looking content
    - No obvious prompt-injection patterns in goals, decisions, or banned entries
    """
    import re as _re
    warnings: list[str] = []

    REQUIRED_KEYS = {"project", "goals", "stack", "milestones", "decisions",
                     "banned_approaches", "updated_at"}
    missing = REQUIRED_KEYS - set(ctx.keys())
    if missing:
        warnings.append(f"Missing keys in context file: {', '.join(sorted(missing))}")

    # Field length caps
    project = ctx.get("project", {})
    for field in ("name", "description", "version"):
        val = project.get(field, "")
        if isinstance(val, str) and len(val) > 500:
            warnings.append(f"project.{field} exceeds 500 characters \u2014 consider shortening.")

    # Injection pattern check on free-text list fields
    INJECTION_RE = _re.compile(
        r'ignore\s+(previous|above|prior|all)|override\s+(policy|restriction|rule)|'
        r'system\s*prompt|you\s+are\s+(now|a)|act\s+as\s+(if|a|an)|'
        r'pretend\s+(to|that|you)|bypass\s+(security|check|restriction)|'
        r'disregard\s+(policy|rule)|admin\s+(mode|access|override)|'
        r'\bsudo\b|\bjailbreak\b',
        _re.IGNORECASE,
    )

    def _check_text(label: str, text: str) -> None:
        if INJECTION_RE.search(text):
            warnings.append(
                f"Possible injection pattern detected in {label}: {text[:80]!r}"
            )
        if len(text) > 2000:
            warnings.append(f"{label} entry exceeds 2000 characters \u2014 review before injecting.")

    for i, goal in enumerate(ctx.get("goals", [])):
        if isinstance(goal, str):
            _check_text(f"goals[{i}]", goal)

    for i, dec in enumerate(ctx.get("decisions", [])):
        if isinstance(dec, dict):
            dec_dict = cast(dict[str, object], dec)
            for fld in ("decision", "rationale"):
                fld_val = dec_dict.get(fld)
                if isinstance(fld_val, str):
                    _check_text(f"decisions[{i}].{fld}", fld_val)
        elif isinstance(dec, str):
            _check_text(f"decisions[{i}]", dec)

    for i, banned in enumerate(ctx.get("banned_approaches", [])):
        if isinstance(banned, str):
            _check_text(f"banned_approaches[{i}]", banned)

    return warnings


def _load() -> dict[str, Any]:
    if not CONTEXT_PATH.exists():
        print(
            f"[context_manager] No project context found at {CONTEXT_PATH}.\n"
            "Run: python context_manager.py init --name \"YourProject\"",
            file=sys.stderr
        )
        sys.exit(1)
    with CONTEXT_PATH.open("r", encoding="utf-8") as fh:
        return json.load(fh)


def _save(ctx: dict[str, Any]) -> None:
    ctx["updated_at"] = _now_iso()
    CONTEXT_PATH.parent.mkdir(parents=True, exist_ok=True)
    with CONTEXT_PATH.open("w", encoding="utf-8") as fh:
        json.dump(ctx, fh, indent=2)
        fh.write("\n")


def _audit(action: str, detail: dict[str, Any]) -> None:
    entry: dict[str, Any] = {
        "timestamp": _now_iso(),
        "action": action,
        "details": {"source": "context_manager", **detail}
    }
    AUDIT_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
    with AUDIT_LOG_PATH.open("a", encoding="utf-8") as fh:
        fh.write(json.dumps(entry) + "\n")


# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------

def cmd_init(args: argparse.Namespace) -> int:
    if CONTEXT_PATH.exists():
        print(f"[context_manager] Context file already exists at {CONTEXT_PATH}.")
        print("Use 'update' to change individual sections, or delete the file to reinitialise.")
        return 1

    ctx = json.loads(json.dumps(EMPTY_CONTEXT))  # deep copy
    ctx["project"]["name"] = args.name
    ctx["project"]["description"] = args.description or ""
    ctx["project"]["version"] = args.version or ""
    _save(ctx)
    _audit("init", {"name": args.name, "version": args.version})
    print(f"[context_manager] Project context initialised: {CONTEXT_PATH}")
    return 0


def cmd_show(args: argparse.Namespace) -> int:  # noqa: ARG001
    ctx = _load()
    warnings = _validate_context(ctx)
    if warnings:
        print("[context_manager] VALIDATION WARNINGS — review before injecting:", file=sys.stderr)
        for w in warnings:
            print(f"  ! {w}", file=sys.stderr)
    print(json.dumps(ctx, indent=2))
    return 0


def cmd_inject(args: argparse.Namespace) -> int:
    """Print a formatted block suitable for injection into an agent system prompt."""
    ctx = _load()
    warnings = _validate_context(ctx)
    if warnings:
        print("[context_manager] VALIDATION WARNINGS \u2014 context has potential issues:", file=sys.stderr)
        for w in warnings:
            print(f"  ! {w}", file=sys.stderr)
        if not getattr(args, "force", False):
            print(
                "[context_manager] ERROR: Injection blocked. Context contains potential prompt-injection "
                "content. Use --force to override (only in trusted, controlled environments).",
                file=sys.stderr,
            )
            return 1
        print("[context_manager] --force: proceeding with inject despite warnings.", file=sys.stderr)
    p = ctx.get("project", {})

    lines: list[str] = []
    lines.append("## Project Context (Layer 3 — Persistent Memory)")
    lines.append("")

    if p.get("name"):
        name_str = p["name"]
        if p.get("version"):
            name_str += f" v{p['version']}"
        lines.append(f"**Project:** {name_str}")
    if p.get("description"):
        lines.append(f"**Description:** {p['description']}")
    lines.append("")

    goals = ctx.get("goals", [])
    if goals:
        lines.append("### Goals")
        for g in goals:
            lines.append(f"- {g}")
        lines.append("")

    stack = ctx.get("stack", {})
    if stack:
        lines.append("### Tech Stack")
        for k, v in stack.items():
            lines.append(f"- **{k}**: {v}")
        lines.append("")

    milestones = ctx.get("milestones", {})
    in_progress = milestones.get("in_progress", [])
    planned = milestones.get("planned", [])
    completed = milestones.get("completed", [])
    if in_progress or planned or completed:
        lines.append("### Milestones")
        for item in in_progress:
            lines.append(f"- 🔄 {item} *(in progress)*")
        for item in planned:
            lines.append(f"- ⏳ {item}")
        for item in completed:
            lines.append(f"- ✅ {item}")
        lines.append("")

    decisions = ctx.get("decisions", [])
    if decisions:
        lines.append("### Architecture Decisions")
        for d in decisions:
            if isinstance(d, dict):
                d_typed: dict[str, Any] = cast(dict[str, Any], d)
                dec: str = str(d_typed.get("decision", d))
                rat: str = str(d_typed.get("rationale", ""))
                lines.append(f"- **{dec}**" + (f" — {rat}" if rat else ""))
            else:
                lines.append(f"- {d}")
        lines.append("")

    banned = ctx.get("banned_approaches", [])
    if banned:
        lines.append("### Banned Approaches")
        for b in banned:
            lines.append(f"- ❌ {b}")
        lines.append("")

    lines.append(f"*Context last updated: {ctx.get('updated_at', 'unknown')}*")

    print("\n".join(lines))
    return 0


def cmd_update(args: argparse.Namespace) -> int:
    ctx = _load()
    section = args.section

    if section == "decisions":
        if not args.add:
            print("[context_manager] --add is required for section 'decisions'", file=sys.stderr)
            return 1
        entry: Any = json.loads(args.add)
        ctx.setdefault("decisions", []).append(entry)
        _audit("update_decisions", {"added": entry})

    elif section == "milestones":
        milestones = ctx.setdefault("milestones", {"completed": [], "in_progress": [], "planned": []})
        if args.complete:
            name = args.complete
            # Move from in_progress or planned → completed
            for bucket in ("in_progress", "planned"):
                lst: list[Any] = milestones.setdefault(bucket, [])
                if name in lst:
                    lst.remove(name)
            milestones.setdefault("completed", []).append(name)
            _audit("milestone_complete", {"name": name})
        elif args.add:
            entry: Any = json.loads(args.add)
            if isinstance(entry, dict):
                for bucket in ("planned", "in_progress", "completed"):
                    if bucket in entry:
                        milestones.setdefault(bucket, []).append(entry[bucket])
                        _audit("milestone_add", {"bucket": bucket, "name": entry[bucket]})
            else:
                milestones.setdefault("planned", []).append(str(entry))
                _audit("milestone_add", {"bucket": "planned", "name": str(entry)})
        else:
            print("[context_manager] Provide --add or --complete for section 'milestones'", file=sys.stderr)
            return 1

    elif section == "stack":
        if not args.set:
            print("[context_manager] --set is required for section 'stack'", file=sys.stderr)
            return 1
        updates = json.loads(args.set)
        ctx.setdefault("stack", {}).update(updates)
        _audit("update_stack", {"updates": updates})

    elif section == "goals":
        if not args.add:
            print("[context_manager] --add is required for section 'goals'", file=sys.stderr)
            return 1
        ctx.setdefault("goals", []).append(args.add)
        _audit("update_goals", {"added": args.add})

    elif section == "banned":
        if not args.add:
            print("[context_manager] --add is required for section 'banned'", file=sys.stderr)
            return 1
        ctx.setdefault("banned_approaches", []).append(args.add)
        _audit("update_banned", {"added": args.add})

    elif section == "project":
        if not args.set:
            print("[context_manager] --set is required for section 'project'", file=sys.stderr)
            return 1
        updates = json.loads(args.set)
        ctx.setdefault("project", {}).update(updates)
        _audit("update_project", {"updates": updates})

    else:
        print(f"[context_manager] Unknown section '{section}'. "
              "Valid: decisions, milestones, stack, goals, banned, project", file=sys.stderr)
        return 1

    _save(ctx)
    print(f"[context_manager] Section '{section}' updated.")
    return 0


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="context_manager.py",
        description="Project Context Manager — Layer-3 persistent memory for agent swarms"
    )
    sub = parser.add_subparsers(dest="command", required=True)

    # init
    p_init = sub.add_parser("init", help="Initialise a new project context file")
    p_init.add_argument("--name", required=True, help="Project name")
    p_init.add_argument("--description", default="", help="Short project description")
    p_init.add_argument("--version", default="", help="Current project version")

    # show
    sub.add_parser("show", help="Print the full context as JSON")

    # inject
    p_inject = sub.add_parser("inject", help="Print formatted context for agent system-prompt injection")
    p_inject.add_argument(
        "--force",
        action="store_true",
        help="Proceed with injection even when validation warnings are present (prompt-injection risk — only use in trusted environments)",
    )

    # update
    p_update = sub.add_parser("update", help="Update a specific context section")
    p_update.add_argument(
        "--section", required=True,
        choices=["decisions", "milestones", "stack", "goals", "banned", "project"],
        help="Section to update"
    )
    p_update.add_argument("--add", help="JSON string or plain string to append")
    p_update.add_argument("--set", help="JSON object to merge/set (used by stack and project)")
    p_update.add_argument("--complete", help="Mark a milestone as completed (milestones section)")

    # Global --env flag on root parser
    parser.add_argument(
        "--env",
        default="",
        help="Target environment (dev|st|sit|qa|sandbox|preprod|prod). Overrides NETWORK_AI_ENV."
    )

    return parser


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    # Re-resolve data paths if --env was provided explicitly.
    # Use globals() to avoid Pyright reportConstantRedefinition on uppercase names.
    if args.env:
        _data = _resolve_data_dir(args.env)
        globals()['CONTEXT_PATH'] = _data / "project-context.json"
        globals()['AUDIT_LOG_PATH'] = _data / "audit_log.jsonl"

    dispatch = {
        "init": cmd_init,
        "show": cmd_show,
        "inject": cmd_inject,
        "update": cmd_update,
    }
    return dispatch[args.command](args)


if __name__ == "__main__":
    sys.exit(main())


path: scripts/revoke_token.py
content: #!/usr/bin/env python3
# SECURITY: This script makes NO network calls and spawns NO subprocesses.
# All I/O is local file operations only:
#   READS:  data[/<env>]/active_grants.json, data[/<env>]/audit_log.jsonl
#   WRITES: data[/<env>]/active_grants.json, data[/<env>]/audit_log.jsonl
# Imports used: argparse, json, os, re, sys, datetime, pathlib, typing
# No imports of: requests, socket, subprocess, urllib, http, ssl, ftplib, smtplib
"""
Revoke Grant Token & TTL Enforcement

Revoke permission tokens and automatically cleanup expired grants.

Usage:
    python revoke_token.py TOKEN           # Revoke specific token
    python revoke_token.py --cleanup       # Remove all expired tokens
    python revoke_token.py --list-expired  # List expired tokens without removing

Example:
    python revoke_token.py grant_a1b2c3d4e5f6
    python revoke_token.py --cleanup --json
"""

import argparse
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


def _resolve_data_dir(env: str = "") -> Path:
    """Return the active data directory, scoped to <env> when set."""
    _env = env or os.environ.get("NETWORK_AI_ENV", "")
    base = Path(__file__).parent.parent / "data"
    if _env:
        if not re.match(r'^[a-zA-Z0-9_-]+$', _env):
            raise ValueError(f"Invalid NETWORK_AI_ENV value: {_env!r}")
        return base / _env
    return base


GRANTS_FILE = _resolve_data_dir() / "active_grants.json"
AUDIT_LOG = _resolve_data_dir() / "audit_log.jsonl"


def log_audit(action: str, details: dict[str, Any]) -> None:
    """Append entry to audit log."""
    AUDIT_LOG.parent.mkdir(exist_ok=True)
    entry: dict[str, Any] = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "action": action,
        "details": details
    }
    with open(AUDIT_LOG, "a") as f:
        f.write(json.dumps(entry) + "\n")


def revoke_token(token: str) -> dict[str, Any]:
    """Revoke a grant token."""
    if not GRANTS_FILE.exists():
        return {
            "revoked": False,
            "reason": "No grants file found"
        }
    
    try:
        grants = json.loads(GRANTS_FILE.read_text())
    except json.JSONDecodeError:
        return {
            "revoked": False,
            "reason": "Invalid grants file"
        }
    
    if token not in grants:
        return {
            "revoked": False,
            "reason": "Token not found"
        }
    
    grant = grants.pop(token)
    GRANTS_FILE.write_text(json.dumps(grants, indent=2))
    
    log_audit("permission_revoked", {
        "token": token,
        "original_grant": grant
    })
    
    return {
        "revoked": True,
        "grant": grant
    }


def is_token_expired(grant: dict[str, Any]) -> bool:
    """Check if a grant token has expired."""
    expires_at = grant.get("expires_at")
    if not expires_at:
        return False
    try:
        expiry_time = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
        return datetime.now(timezone.utc) > expiry_time
    except (ValueError, AttributeError):
        return False


def list_expired_tokens() -> dict[str, Any]:
    """List all expired tokens without removing them."""
    if not GRANTS_FILE.exists():
        return {"expired_tokens": [], "total_grants": 0}
    
    try:
        grants = json.loads(GRANTS_FILE.read_text())
    except json.JSONDecodeError:
        return {"error": "Invalid grants file"}
    
    expired: list[dict[str, Any]] = []
    now = datetime.now(timezone.utc)
    
    for token, grant in grants.items():
        if is_token_expired(grant):
            expired.append({
                "token": token,
                "agent": grant.get("agent_id"),
                "resource": grant.get("resource_type"),
                "expired_at": grant.get("expires_at")
            })
    
    return {
        "expired_tokens": expired,
        "expired_count": len(expired),
        "total_grants": len(grants),
        "checked_at": now.isoformat()
    }


def cleanup_expired_tokens() -> dict[str, Any]:
    """Remove all expired tokens from active grants (TTL enforcement)."""
    if not GRANTS_FILE.exists():
        return {
            "cleaned": 0,
            "remaining": 0,
            "message": "No grants file found"
        }
    
    try:
        grants = json.loads(GRANTS_FILE.read_text())
    except json.JSONDecodeError:
        return {
            "cleaned": 0,
            "error": "Invalid grants file"
        }
    
    original_count = len(grants)
    expired_tokens: list[dict[str, Any]] = []
    
    # Find and remove expired tokens
    tokens_to_remove: list[str] = []
    for token, grant in grants.items():
        if is_token_expired(grant):
            tokens_to_remove.append(token)
            expired_tokens.append({
                "token": token,
                "agent": grant.get("agent_id"),
                "resource": grant.get("resource_type"),
                "expired_at": grant.get("expires_at")
            })
    
    for token in tokens_to_remove:
        grants.pop(token)
    
    # Write back cleaned grants
    GRANTS_FILE.write_text(json.dumps(grants, indent=2))
    
    # Log the cleanup
    if expired_tokens:
        log_audit("ttl_cleanup", {
            "expired_count": len(expired_tokens),
            "expired_tokens": expired_tokens,
            "remaining_grants": len(grants)
        })
    
    return {
        "cleaned": len(expired_tokens),
        "expired_tokens": expired_tokens,
        "remaining": len(grants),
        "original_count": original_count,
        "cleaned_at": datetime.now(timezone.utc).isoformat()
    }


def main():
    parser = argparse.ArgumentParser(
        description="Revoke permission grant tokens and enforce TTL cleanup"
    )
    parser.add_argument("token", nargs="?", help="Grant token to revoke")
    parser.add_argument("--cleanup", action="store_true", 
                        help="Remove all expired tokens (TTL enforcement)")
    parser.add_argument("--list-expired", action="store_true",
                        help="List expired tokens without removing")
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    parser.add_argument(
        "--env",
        default="",
        help="Target environment (dev|st|sit|qa|sandbox|preprod|prod). Overrides NETWORK_AI_ENV."
    )

    args = parser.parse_args()

    # Re-resolve data paths if --env was provided explicitly
    global GRANTS_FILE, AUDIT_LOG
    if args.env:
        GRANTS_FILE = _resolve_data_dir(args.env) / "active_grants.json"
        AUDIT_LOG = _resolve_data_dir(args.env) / "audit_log.jsonl"

    # Handle --list-expired
    if args.list_expired:
        result = list_expired_tokens()
        if args.json:
            print(json.dumps(result, indent=2))
        else:
            expired = result.get("expired_tokens", [])
            if expired:
                print(f"⏰ Found {len(expired)} expired token(s):")
                for t in expired:
                    print(f"   • {t['token'][:20]}... ({t['agent']} → {t['resource']})")
                    print(f"     Expired: {t['expired_at']}")
            else:
                print("✅ No expired tokens found")
            print(f"\n   Total grants: {result.get('total_grants', 0)}")
        sys.exit(0)
    
    # Handle --cleanup
    if args.cleanup:
        result = cleanup_expired_tokens()
        if args.json:
            print(json.dumps(result, indent=2))
        else:
            cleaned = result.get("cleaned", 0)
            if cleaned > 0:
                print(f"🧹 TTL Cleanup Complete")
                print(f"   Removed: {cleaned} expired token(s)")
                for t in result.get("expired_tokens", []):
                    print(f"   • {t['token'][:20]}... ({t['agent']})")
            else:
                print("✅ No expired tokens to clean")
            print(f"   Remaining active grants: {result.get('remaining', 0)}")
        sys.exit(0)
    
    # Handle single token revocation
    if not args.[REDACTED_SECRET])
        sys.exit(1)
    
    result = revoke_token(args.token)
    
    if args.json:
        print(json.dumps(result, indent=2))
    else:
        if result["revoked"]:
            grant = result["grant"]
            print("✅ Token REVOKED")
            print(f"   Agent: {grant.get('agent_id')}")
            print(f"   Resource: {grant.get('resource_type')}")
        else:
            print("❌ Revocation FAILED")
            print(f"   Reason: {result.get('reason')}")
    
    sys.exit(0 if result.get("revoked") or result.get("cleaned", 0) >= 0 else 1)


if __name__ == "__main__":
    main()


path: scripts/swarm_guard.py
content: #!/usr/bin/env python3
# SECURITY: This script makes NO network calls and spawns NO subprocesses.
# All I/O is local file operations only.
# Base data directory is data/ (root) or data/<env>/ when NETWORK_AI_ENV or --env is set.
#   READS:  <data_dir>/swarm_budgets.json, <data_dir>/heartbeats.json,
#           <data_dir>/audit_log.jsonl, <data_dir>/task_tracking.json,
#           <data_dir>/agent_health.json, <data_dir>/budget_tracking.json
#   WRITES: <data_dir>/swarm_budgets.json, <data_dir>/heartbeats.json,
#           <data_dir>/audit_log.jsonl, <data_dir>/task_tracking.json,
#           <data_dir>/agent_health.json, <data_dir>/budget_tracking.json
# Imports used: argparse, json, os, sys, datetime, pathlib, typing
# No imports of: requests, socket, subprocess, urllib, http, ssl, ftplib, smtplib
"""
Swarm Guard - Prevents Handoff Tax, Silent Failures, and Budget Overruns

Three critical issues in multi-agent swarms:
1. HANDOFF TAX: Agents waste tokens "talking about" work instead of doing it
2. SILENT FAILURE: One agent fails, others keep working on bad data
3. BUDGET OVERRUN: Infinite loops burn $500 in API credits in an hour

Usage:
    python swarm_guard.py check-handoff --task-id TASK_ID
    python swarm_guard.py validate-result --task-id TASK_ID --agent AGENT_ID
    python swarm_guard.py health-check --agent AGENT_ID
    python swarm_guard.py supervisor-review --task-id TASK_ID
    
    # Budget/Cost Awareness:
    python swarm_guard.py budget-init --task-id TASK_ID --budget 10000
    python swarm_guard.py budget-check --task-id TASK_ID
    python swarm_guard.py budget-spend --task-id TASK_ID --tokens 500 --reason "API call"

Examples:
    python swarm_guard.py check-handoff --task-id "task_001"
    python swarm_guard.py budget-init --task-id "task_001" --budget 10000
    python swarm_guard.py budget-spend --task-id "task_001" --tokens 500 --reason "LLM call"
"""

import argparse
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional, Union

# Configuration
def _resolve_data_dir(env: str = "") -> Path:
    """Return the active data directory, scoped to <env> when set."""
    import re as _re
    _env = env or os.environ.get("NETWORK_AI_ENV", "")
    base = Path(__file__).parent.parent / "data"
    if _env:
        if not _re.match(r'^[a-zA-Z0-9_-]+$', _env):
            raise ValueError(f"Invalid NETWORK_AI_ENV value: {_env!r}")
        return base / _env
    return base

DATA_DIR = _resolve_data_dir()
BLACKBOARD_PATH = DATA_DIR / "swarm-blackboard.md"
AUDIT_LOG = DATA_DIR / "audit_log.jsonl"

# ============================================================================
# HANDOFF TAX LIMITS
# ============================================================================

# Maximum handoffs before forcing action
MAX_HANDOFFS_PER_TASK = 3

# Maximum message size (chars) - forces concise communication
MAX_HANDOFF_MESSAGE_SIZE = 500

# Minimum "action ratio" - at least 60% of exchanges should produce artifacts
MIN_ACTION_RATIO = 0.6

# Maximum time in "planning" phase before requiring output
MAX_PLANNING_SECONDS = 120

# ============================================================================
# SILENT FAILURE DETECTION
# ============================================================================

# Heartbeat timeout - agent considered failed if no update in this time
HEARTBEAT_TIMEOUT_SECONDS = 60

# Result validation rules
REQUIRED_RESULT_FIELDS = ["status", "output", "confidence"]

# Confidence threshold for auto-approval
MIN_CONFIDENCE_THRESHOLD = 0.7

# ============================================================================
# COST AWARENESS / TOKEN BUDGET
# ============================================================================

# Default max token budget per task (prevents infinite loops)
DEFAULT_MAX_TOKEN_BUDGET = 50000

# Warning threshold (percentage of budget)
BUDGET_WARNING_THRESHOLD = 0.75  # Warn at 75%

# Hard stop threshold (percentage of budget)
BUDGET_HARD_STOP_THRESHOLD = 1.0  # Stop at 100%

# Estimated token costs for common operations
TOKEN_COSTS = {
    "handoff": 150,        # Estimated tokens per handoff message
    "api_call": 500,       # Average API call
    "llm_query": 1000,     # LLM inference call
    "file_read": 200,      # Reading a file
    "file_write": 300,     # Writing a file
    "validation": 100,     # Result validation
}


def log_audit(action: str, details: dict[str, Any]) -> None:
    """Append entry to audit log."""
    AUDIT_LOG.parent.mkdir(exist_ok=True)
    entry: dict[str, Any] = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "action": action,
        "details": details
    }
    with open(AUDIT_LOG, "a") as f:
        f.write(json.dumps(entry) + "\n")


class SwarmGuard:
    """Monitors swarm health, prevents common failure modes, and enforces budgets."""
    
    def __init__(self):
        self.data_dir = DATA_DIR
        self.data_dir.mkdir(exist_ok=True)
        self.task_log_path = self.data_dir / "task_tracking.json"
        self.health_log_path = self.data_dir / "agent_health.json"
        self.budget_log_path = self.data_dir / "budget_tracking.json"
        self._load_state()
    
    def _load_state(self) -> None:
        """Load tracking state from disk."""
        self.task_tracking: dict[str, Any] = {}
        self.agent_health: dict[str, Any] = {}
        self.budget_tracking: dict[str, Any] = {}
        
        if self.task_log_path.exists():
            try:
                self.task_tracking = json.loads(self.task_log_path.read_text())
            except json.JSONDecodeError:
                pass  # corrupt file — keep default empty dict
            try:
                self.agent_health = json.loads(self.health_log_path.read_text())
            except json.JSONDecodeError:
                pass  # corrupt file — keep default empty dict
            try:
                self.budget_tracking = json.loads(self.budget_log_path.read_text())
            except json.JSONDecodeError:
                pass  # corrupt file — keep default empty dict
    
    def _save_state(self) -> None:
        """Persist tracking state to disk."""
        self.task_log_path.write_text(json.dumps(self.task_tracking, indent=2))
        self.health_log_path.write_text(json.dumps(self.agent_health, indent=2))
        self.budget_log_path.write_text(json.dumps(self.budget_tracking, indent=2))
    
    # ========================================================================
    # HANDOFF TAX PREVENTION
    # ========================================================================
    
    def record_handoff(self, task_id: str, from_agent: str, to_agent: str, 
                       message: str, has_artifact: bool = False) -> dict[str, Any]:
        """
        Record a handoff and check for Handoff Tax violations.
        
        Returns warnings if:
        - Too many handoffs for this task
        - Message is too verbose
        - Low action ratio (lots of talk, no artifacts)
        """
        if task_id not in self.task_tracking:
            self.task_tracking[task_id] = {
                "created_at": datetime.now(timezone.utc).isoformat(),
                "handoffs": [],
                "artifacts_produced": 0,
                "status": "active"
            }
        
        task = self.task_tracking[task_id]
        
        # Record this handoff
        handoff_record: dict[str, Union[str, int, bool]] = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "from": from_agent,
            "to": to_agent,
            "message_length": len(message),
            "has_artifact": has_artifact
        }
        task["handoffs"].append(handoff_record)
        
        if has_artifact:
            task["artifacts_produced"] += 1
        
        self._save_state()
        
        # Check for violations
        warnings: list[str] = []
        violations: list[str] = []
        
        handoff_count = len(task["handoffs"])
        
        # Check: Too many handoffs?
        if handoff_count > MAX_HANDOFFS_PER_TASK:
            violations.append(
                f"HANDOFF_TAX: {handoff_count} handoffs exceeds limit of {MAX_HANDOFFS_PER_TASK}. "
                "Stop discussing, start producing output!"
            )
        elif handoff_count == MAX_HANDOFFS_PER_TASK:
            warnings.append(
                f"WARNING: This is handoff #{handoff_count}. Next handoff must include final output."
            )
        
        # Check: Message too verbose?
        if len(message) > MAX_HANDOFF_MESSAGE_SIZE:
            violations.append(
                f"VERBOSE_HANDOFF: Message is {len(message)} chars, limit is {MAX_HANDOFF_MESSAGE_SIZE}. "
                "Be concise! Include only: instruction, constraints, expected output."
            )
        
        # Check: Action ratio (only after 2+ handoffs)
        if handoff_count >= 2:
            action_ratio = task["artifacts_produced"] / handoff_count
            if action_ratio < MIN_ACTION_RATIO:
                warnings.append(
                    f"LOW_ACTION_RATIO: Only {task['artifacts_produced']}/{handoff_count} "
                    f"handoffs produced artifacts ({action_ratio:.0%}). "
                    f"Target is {MIN_ACTION_RATIO:.0%}."
                )
        
        # Check: Time in planning phase
        created = datetime.fromisoformat(task["created_at"].replace("Z", "+00:00"))
        elapsed = (datetime.now(timezone.utc) - created).total_seconds()
        
        if elapsed > MAX_PLANNING_SECONDS and task["artifacts_produced"] == 0:
            violations.append(
                f"PLANNING_TIMEOUT: {elapsed:.0f}s elapsed with no artifacts. "
                "Produce output now or abort task."
            )
        
        return {
            "task_id": task_id,
            "handoff_number": handoff_count,
            "artifacts_produced": task["artifacts_produced"],
            "warnings": warnings,
            "violations": violations,
            "blocked": len(violations) > 0
        }
    
    def check_handoff_status(self, task_id: str) -> dict[str, Any]:
        """Get current handoff tax status for a task."""
        if task_id not in self.task_tracking:
            return {
                "task_id": task_id,
                "exists": False,
                "handoffs": 0,
                "remaining": MAX_HANDOFFS_PER_TASK,
                "status": "not_found"
            }
        
        task = self.task_tracking[task_id]
        handoff_count = len(task["handoffs"])
        
        return {
            "task_id": task_id,
            "exists": True,
            "handoffs": handoff_count,
            "remaining": max(0, MAX_HANDOFFS_PER_TASK - handoff_count),
            "artifacts_produced": task["artifacts_produced"],
            "action_ratio": task["artifacts_produced"] / handoff_count if handoff_count > 0 else 1.0,
            "status": task["status"]
        }
    
    # ========================================================================
    # SILENT FAILURE PREVENTION
    # ========================================================================
    
    def agent_heartbeat(self, agent_id: str, task_id: Optional[str] = None,
                        status: str = "active") -> dict[str, Any]:
        """
        Record agent heartbeat to detect silent failures.
        Agents should call this periodically while working.
        """
        now = datetime.now(timezone.utc).isoformat()
        
        if agent_id not in self.agent_health:
            self.agent_health[agent_id] = {
                "first_seen": now,
                "heartbeats": 0,
                "failures": 0
            }
        
        self.agent_health[agent_id].update({
            "last_heartbeat": now,
            "current_task": task_id,
            "status": status,
            "heartbeats": self.agent_health[agent_id].get("heartbeats", 0) + 1
        })
        
        self._save_state()
        
        return {
            "agent_id": agent_id,
            "recorded": True,
            "timestamp": now
        }
    
    def check_agent_health(self, agent_id: str) -> dict[str, Any]:
        """
        Check if an agent is healthy (recent heartbeat).
        Returns failure status if agent hasn't reported in.
        """
        if agent_id not in self.agent_health:
            return {
                "agent_id": agent_id,
                "healthy": False,
                "reason": "UNKNOWN_AGENT",
                "recommendation": "Agent has never reported. Verify agent exists."
            }
        
        agent = self.agent_health[agent_id]
        last_heartbeat = agent.get("last_heartbeat")
        
        if not last_heartbeat:
            return {
                "agent_id": agent_id,
                "healthy": False,
                "reason": "NO_HEARTBEAT",
                "recommendation": "Agent registered but never sent heartbeat."
            }
        
        # Check if heartbeat is recent
        last_time = datetime.fromisoformat(last_heartbeat.replace("Z", "+00:00"))
        elapsed = (datetime.now(timezone.utc) - last_time).total_seconds()
        
        if elapsed > HEARTBEAT_TIMEOUT_SECONDS:
            return {
                "agent_id": agent_id,
                "healthy": False,
                "reason": "STALE_HEARTBEAT",
                "seconds_since_heartbeat": elapsed,
                "timeout_threshold": HEARTBEAT_TIMEOUT_SECONDS,
                "current_task": agent.get("current_task"),
                "recommendation": f"Agent silent for {elapsed:.0f}s. Assume FAILED. "
                                  "Do NOT use any pending results from this agent."
            }
        
        return {
            "agent_id": agent_id,
            "healthy": True,
            "status": agent.get("status", "unknown"),
            "current_task": agent.get("current_task"),
            "seconds_since_heartbeat": elapsed
        }
    
    def validate_result(self, task_id: str, agent_id: str, 
                        result: dict[str, Any]) -> dict[str, Any]:
        """
        Validate an agent's result before other agents use it.
        Prevents cascade failures from bad data.
        """
        issues: list[str] = []
        warnings: list[str] = []
        
        # Check agent health first
        health = self.check_agent_health(agent_id)
        if not health["healthy"]:
            issues.append(f"UNHEALTHY_AGENT: {health['reason']} - {health['recommendation']}")
        
        # Check required fields
        for field in REQUIRED_RESULT_FIELDS:
            if field not in result:
                issues.append(f"MISSING_FIELD: Result must include '{field}'")
        
        # Check status
        if result.get("status") == "error":
            issues.append(f"ERROR_STATUS: Agent reported error: {result.get('error', 'unknown')}")
        
        # Check confidence
        confidence = result.get("confidence", 0)
        if confidence < MIN_CONFIDENCE_THRESHOLD:
            warnings.append(
                f"LOW_CONFIDENCE: Agent confidence is {confidence:.0%}, "
                f"threshold is {MIN_CONFIDENCE_THRESHOLD:.0%}. "
                "Consider supervisor review."
            )
        
        # Check for empty output
        output = result.get("output")
        if output is None or output == "" or output == {}:
            issues.append("EMPTY_OUTPUT: Result contains no meaningful output")
        
        valid = len(issues) == 0
        
        # Record validation
        if task_id in self.task_tracking:
            if "validations" not in self.task_tracking[task_id]:
                self.task_tracking[task_id]["validations"] = []
            
            self.task_tracking[task_id]["validations"].append({
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "agent_id": agent_id,
                "valid": valid,
                "issues": issues
            })
            self._save_state()
        
        return {
            "task_id": task_id,
            "agent_id": agent_id,
            "valid": valid,
            "usable": valid,  # Other agents can use this result
            "issues": issues,
            "warnings": warnings,
            "recommendation": "APPROVED - Result can be used by other agents" if valid 
                            else "BLOCKED - Do NOT propagate this result. Fix issues or restart task."
        }
    
    def supervisor_review(self, task_id: str) -> dict[str, Any]:
        """
        Supervisor-level review of entire task state.
        Checks for cascade failures, zombie tasks, and quality issues.
        """
        if task_id not in self.task_tracking:
            return {
                "task_id": task_id,
                "found": False,
                "verdict": "UNKNOWN_TASK"
            }
        
        task = self.task_tracking[task_id]
        issues: list[str] = []
        recommendations: list[str] = []
        
        # Check task age
        created = datetime.fromisoformat(task["created_at"].replace("Z", "+00:00"))
        age_seconds = (datetime.now(timezone.utc) - created).total_seconds()
        
        if age_seconds > 300 and task["status"] == "active":  # 5 min
            issues.append(f"LONG_RUNNING: Task active for {age_seconds/60:.1f} minutes")
            recommendations.append("Consider timeout or manual intervention")
        
        # Check handoff efficiency
        handoffs = task.get("handoffs", [])
        artifacts = task.get("artifacts_produced", 0)
        
        if len(handoffs) > 0:
            efficiency = artifacts / len(handoffs)
            if efficiency < 0.5:
                issues.append(f"INEFFICIENT: Only {efficiency:.0%} of handoffs produced output")
                recommendations.append("Reduce coordination overhead, increase direct work")
        
        # Check validations
        validations = task.get("validations", [])
        failed_validations = [v for v in validations if not v.get("valid")]
        
        if len(failed_validations) > 0:
            issues.append(f"VALIDATION_FAILURES: {len(failed_validations)} results failed validation")
            for v in failed_validations:
                recommendations.append(f"Re-run or fix agent '{v['agent_id']}': {v['issues']}")
        
        # Check for participating agents' health
        participating_agents: set[str] = set()
        for h in handoffs:
            from_agent = h.get("from")
            to_agent = h.get("to")
            if isinstance(from_agent, str):
                participating_agents.add(from_agent)
            if isinstance(to_agent, str):
                participating_agents.add(to_agent)
        
        unhealthy_agents: list[str] = []
        for agent_id in participating_agents:
            health = self.check_agent_health(agent_id)
            if not health["healthy"]:
                unhealthy_agents.append(agent_id)
        
        if unhealthy_agents:
            issues.append(f"UNHEALTHY_AGENTS: {unhealthy_agents}")
            recommendations.append("Do not trust pending results from unhealthy agents")
        
        # Check budget status
        budget_status = self.check_budget(task_id)
        if budget_status.get("initialized"):
            usage_pct = budget_status.get("usage_percentage", 0)
            if usage_pct >= 100:
                issues.append(f"BUDGET_EXCEEDED: {usage_pct:.0f}% of budget used")
                recommendations.append("Task must stop - budget exhausted")
            elif usage_pct >= 75:
                issues.append(f"BUDGET_WARNING: {usage_pct:.0f}% of budget used")
                recommendations.append("Complete task soon or request budget increase")
        
        # Verdict
        if len(issues) == 0:
            verdict = "APPROVED"
            status = "healthy"
        elif any("VALIDATION_FAILURES" in i or "UNHEALTHY_AGENTS" in i or "BUDGET_EXCEEDED" in i for i in issues):
            verdict = "BLOCKED"
            status = "critical"
        else:
            verdict = "WARNING"
            status = "degraded"
        
        return {
            "task_id": task_id,
            "found": True,
            "verdict": verdict,
            "status": status,
            "age_seconds": age_seconds,
            "handoffs": len(handoffs),
            "artifacts": artifacts,
            "issues": issues,
            "recommendations": recommendations
        }
    
    # ========================================================================
    # COST AWARENESS / TOKEN BUDGET
    # ========================================================================
    
    def init_budget(self, task_id: str, max_tokens: int = DEFAULT_MAX_TOKEN_BUDGET,
                    description: str = "") -> dict[str, Any]:
        """
        Initialize a token budget for a task.
        This MUST be called before any work begins to enable cost tracking.
        """
        if task_id in self.budget_tracking:
            return {
                "initialized": False,
                "error": f"Budget already exists for task '{task_id}'. Use budget-check to view."
            }
        
        self.budget_tracking[task_id] = {
            "max_tokens": max_tokens,
            "used_tokens": 0,
            "remaining_tokens": max_tokens,
            "created_at": datetime.now(timezone.utc).isoformat(),
            "description": description,
            "spending_log": [],
            "status": "active"
        }
        
        self._save_state()
        
        log_audit("budget_initialized", {
            "task_id": task_id,
            "max_tokens": max_tokens,
            "description": description
        })
        
        return {
            "initialized": True,
            "task_id": task_id,
            "max_tokens": max_tokens,
            "message": f"Budget initialized: {max_tokens:,} tokens"
        }
    
    def check_budget(self, task_id: str) -> dict[str, Any]:
        """
        Check current budget status for a task.
        Returns remaining budget, usage percentage, and warnings.
        """
        if task_id not in self.budget_tracking:
            return {
                "task_id": task_id,
                "initialized": False,
                "error": "No budget tracking for this task. Run budget-init first."
            }
        
        budget = self.budget_tracking[task_id]
        usage_pct = (budget["used_tokens"] / budget["max_tokens"]) * 100 if budget["max_tokens"] > 0 else 0
        
        # Determine status
        if usage_pct >= BUDGET_HARD_STOP_THRESHOLD * 100:
            status = "EXHAUSTED"
            can_continue = False
        elif usage_pct >= BUDGET_WARNING_THRESHOLD * 100:
            status = "WARNING"
            can_continue = True
        else:
            status = "OK"
            can_continue = True
        
        return {
            "task_id": task_id,
            "initialized": True,
            "max_tokens": budget["max_tokens"],
            "used_tokens": budget["used_tokens"],
            "remaining_tokens": budget["remaining_tokens"],
            "usage_percentage": usage_pct,
            "status": status,
            "can_continue": can_continue,
            "spending_count": len(budget["spending_log"])
        }
    
    def spend_budget(self, task_id: str, tokens: int, reason: str,
                     agent_id: str = "unknown", operation: str = "unknown") -> dict[str, Any]:
        """
        Record token spending against the task budget.
        This acts as the "Tax Collector" - call before every API/LLM operation.
        
        Returns:
        - allowed: True if spend was recorded
        - blocked: True if budget exceeded (HARD STOP triggered)
        """
        if task_id not in self.budget_tracking:
            return {
                "allowed": False,
                "error": "No budget tracking for this task. Run budget-init first."
            }
        
        budget = self.budget_tracking[task_id]
        
        # Check if we're already over budget
        if budget["status"] == "exhausted":
            return self._trigger_safety_shutdown(task_id, "Budget already exhausted")
        
        # Check if this spend would exceed budget
        new_total = budget["used_tokens"] + tokens
        if new_total > budget["max_tokens"]:
            budget["status"] = "exhausted"
            self._save_state()
            return self._trigger_safety_shutdown(
                task_id, 
                f"Spend of {tokens:,} would exceed budget. "
                f"Current: {budget['used_tokens']:,}/{budget['max_tokens']:,}"
            )
        
        # Record the spend
        spend_record: dict[str, Union[str, int]] = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "tokens": tokens,
            "reason": reason,
            "agent_id": agent_id,
            "operation": operation,
            "running_total": new_total
        }
        
        budget["spending_log"].append(spend_record)
        budget["used_tokens"] = new_total
        budget["remaining_tokens"] = budget["max_tokens"] - new_total
        
        # Check for warning threshold
        usage_pct = (new_total / budget["max_tokens"]) * 100
        warning = None
        if usage_pct >= BUDGET_WARNING_THRESHOLD * 100:
            warning = f"⚠️ Budget at {usage_pct:.0f}% - complete task soon!"
        
        self._save_state()
        
        return {
            "allowed": True,
            "task_id": task_id,
            "tokens_spent": tokens,
            "reason": reason,
            "used_tokens": new_total,
            "remaining_tokens": budget["remaining_tokens"],
            "usage_percentage": usage_pct,
            "warning": warning
        }
    
    def _trigger_safety_shutdown(self, task_id: str, reason: str) -> dict[str, Any]:
        """
        Trigger a safety shutdown when budget is exceeded.
        This is the HARD STOP that prevents runaway costs.
        """
        log_audit("safety_shutdown", {
            "task_id": task_id,
            "reason": reason,
            "timestamp": datetime.now(timezone.utc).isoformat()
        })
        
        # Mark task as terminated in tracking
        if task_id in self.task_tracking:
            self.task_tracking[task_id]["status"] = "budget_terminated"
            self._save_state()
        
        return {
            "allowed": False,
            "blocked": True,
            "task_id": task_id,
            "reason": "SAFETY_SHUTDOWN",
            "message": f"🛑 BUDGET EXCEEDED: Task '{task_id}' ABORTED. {reason}",
            "action_required": "Task terminated. Do NOT continue. Report to supervisor."
        }
    
    def get_budget_report(self, task_id: str) -> dict[str, Any]:
        """Get detailed spending report for a task."""
        if task_id not in self.budget_tracking:
            return {"error": "No budget tracking for this task."}
        
        budget = self.budget_tracking[task_id]
        
        # Aggregate by operation type
        by_operation: dict[str, int] = {}
        by_agent: dict[str, int] = {}
        
        for spend in budget["spending_log"]:
            op = spend.get("operation", "unknown")
            agent = spend.get("agent_id", "unknown")
            tokens = spend.get("tokens", 0)
            
            by_operation[op] = by_operation.get(op, 0) + tokens
            by_agent[agent] = by_agent.get(agent, 0) + tokens
        
        return {
            "task_id": task_id,
            "summary": {
                "max_tokens": budget["max_tokens"],
                "used_tokens": budget["used_tokens"],
                "remaining_tokens": budget["remaining_tokens"],
                "usage_percentage": (budget["used_tokens"] / budget["max_tokens"]) * 100
            },
            "by_operation": by_operation,
            "by_agent": by_agent,
            "spending_log": budget["spending_log"],
            "created_at": budget["created_at"]
        }

    # ========================================================================
    # SESSIONS_SEND INTERCEPTION (Budget-Aware Handoff)
    # ========================================================================

    def intercept_handoff(self, task_id: str, from_agent: str, to_agent: str,
                          message: str, has_artifact: bool = False) -> dict[str, Any]:
        """
        INTERCEPT every sessions_send call to:
        1. Check budget before allowing handoff
        2. Deduct handoff tax automatically
        3. Record the handoff for tracking
        4. Block if budget exceeded or too many handoffs
        
        This is the MAIN entry point that should wrap every sessions_send.
        
        Usage (in orchestrator code):
            result = guard.intercept_handoff(task_id, "orchestrator", "analyst", message)
            if result["allowed"]:
                sessions_send(to_agent, message)  # Proceed with actual handoff
            else:
                # Handle blocked handoff
        """
        result: dict[str, Any] = {
            "allowed": False,
            "task_id": task_id,
            "from_agent": from_agent,
            "to_agent": to_agent
        }
        
        # Step 1: Check if budget exists (initialize if not)
        budget_status = self.check_budget(task_id)
        if not budget_status.get("initialized"):
            # Auto-initialize with default budget for convenience
            self.init_budget(task_id, DEFAULT_MAX_TOKEN_BUDGET, 
                           f"Auto-initialized for handoff from {from_agent}")
            budget_status = self.check_budget(task_id)
        
        # Step 2: Check if we can continue (budget not exhausted)
        if not budget_status.get("can_continue"):
            result["blocked"] = True
            result["reason"] = "BUDGET_EXHAUSTED"
            result["message"] = f"🛑 Cannot handoff: budget exhausted for task '{task_id}'"
            result["budget_status"] = budget_status
            
            log_audit("handoff_blocked", {
                "task_id": task_id,
                "from": from_agent,
                "to": to_agent,
                "reason": "budget_exhausted"
            })
            
            return result
        
        # Step 3: Calculate handoff cost
        base_cost = TOKEN_COSTS["handoff"]
        message_cost = len(message) // 4  # ~4 chars per token
        total_cost = base_cost + message_cost
        
        # Step 4: Deduct from budget
        spend_result = self.spend_budget(
            task_id, 
            total_cost,
            f"Handoff: {from_agent} → {to_agent}",
            from_agent,
            "handoff"
        )
        
        if spend_result.get("blocked"):
            result["blocked"] = True
            result["reason"] = "BUDGET_EXCEEDED"
            result["message"] = spend_result.get("message")
            result["spend_result"] = spend_result
            return result
        
        # Step 5: Record the handoff (checks handoff tax limits)
        handoff_result = self.record_handoff(
            task_id, from_agent, to_agent, message, has_artifact
        )
        
        if handoff_result.get("blocked"):
            result["blocked"] = True
            result["reason"] = "HANDOFF_TAX_EXCEEDED"
            result["message"] = f"🛑 Handoff blocked: {handoff_result['violations']}"
            result["handoff_result"] = handoff_result
            return result
        
        # Step 6: All checks passed - handoff is allowed
        result["allowed"] = True
        result["tokens_spent"] = total_cost
        result["remaining_budget"] = spend_result.get("remaining_tokens")
        result["handoff_number"] = handoff_result.get("handoff_number")
        result["remaining_handoffs"] = handoff_result.get("remaining", 0)
        
        warnings: list[str] = []
        if spend_result.get("warning"):
            warnings.append(str(spend_result["warning"]))
        
        if handoff_result.get("warnings"):
            warnings.extend([str(w) for w in handoff_result["warnings"]])
        
        result["warnings"] = warnings
        
        log_audit("handoff_allowed", {
            "task_id": task_id,
            "from": from_agent,
            "to": to_agent,
            "tokens_spent": total_cost,
            "handoff_number": handoff_result.get("handoff_number")
        })
        
        return result


def main():
    parser = argparse.ArgumentParser(
        description="Swarm Guard - Prevent Handoff Tax, Silent Failures, and Budget Overruns",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Commands:
  check-handoff      Check handoff tax status for a task
  record-handoff     Record a new handoff (with tax checking)
  intercept-handoff  BUDGET-AWARE handoff (wraps sessions_send)
  validate-result    Validate an agent's result before propagation
  health-check       Check if an agent is healthy
  heartbeat          Record agent heartbeat
  supervisor-review  Full supervisor review of task state
  
  Budget Management (Cost Awareness):
  budget-init        Initialize token budget for a task
  budget-check       Check remaining budget
  budget-spend       Record token spending (the "Tax Collector")
  budget-report      Get detailed spending report

Examples:
  %(prog)s check-handoff --task-id "task_001"
  %(prog)s record-handoff --task-id "task_001" --from orchestrator --to analyst --message "Analyze data"
  %(prog)s intercept-handoff --task-id "task_001" --from orchestrator --to analyst --message "Analyze data"
  %(prog)s validate-result --task-id "task_001" --agent analyst --result '{"status":"ok","output":"...","confidence":0.9}'
  %(prog)s health-check --agent data_analyst
  
  # Cost control:
  %(prog)s budget-init --task-id "task_001" --budget 10000
  %(prog)s budget-spend --task-id "task_001" --tokens 500 --reason "LLM query"
  %(prog)s budget-check --task-id "task_001"
"""
    )
    
    parser.add_argument("command", choices=[
        "check-handoff", "record-handoff", "intercept-handoff", "validate-result",
        "health-check", "heartbeat", "supervisor-review",
        "budget-init", "budget-check", "budget-spend", "budget-report"
    ])
    parser.add_argument("--task-id", "-t", help="Task ID")
    parser.add_argument("--agent", "-a", help="Agent ID")
    parser.add_argument("--from", dest="from_agent", help="Source agent (for record-handoff)")
    parser.add_argument("--to", dest="to_agent", help="Target agent (for record-handoff)")
    parser.add_argument("--message", "-m", help="Handoff message")
    parser.add_argument("--artifact", action="store_true", help="Handoff includes artifact")
    parser.add_argument("--result", "-r", help="Result JSON (for validate-result)")
    parser.add_argument("--status", "-s", default="active", help="Agent status (for heartbeat)")
    parser.add_argument("--budget", "-b", type=int, default=DEFAULT_MAX_TOKEN_BUDGET,
                        help=f"Max token budget (default: {DEFAULT_MAX_TOKEN_BUDGET:,})")
    parser.add_argument("--tokens", type=int, help="Tokens to spend (for budget-spend)")
    parser.add_argument("--reason", help="Reason for spending (for budget-spend)")
    parser.add_argument("--operation", "-o", default="unknown", help="Operation type")
    parser.add_argument("--description", "-d", default="", help="Task description")
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    parser.add_argument(
        "--env",
        default="",
        help="Target environment (dev|st|sit|qa|sandbox|preprod|prod). Overrides NETWORK_AI_ENV."
    )
    
    args = parser.parse_args()

    # Re-resolve data paths if --env was provided explicitly
    global DATA_DIR, AUDIT_LOG
    if args.env:
        DATA_DIR = _resolve_data_dir(args.env)
        AUDIT_LOG = DATA_DIR / "audit_log.jsonl"

    guard = SwarmGuard()
    
    if args.command == "check-handoff":
        if not args.task_id:
            print("Error: --task-id required", file=sys.stderr)
            sys.exit(1)
        result = guard.check_handoff_status(args.task_id)
    
    elif args.command == "record-handoff":
        if not all([args.task_id, args.from_agent, args.to_agent, args.message]):
            print("Error: --task-id, --from, --to, --message required", file=sys.stderr)
            sys.exit(1)
        result = guard.record_handoff(
            args.task_id, args.from_agent, args.to_agent, 
            args.message, args.artifact
        )
    
    elif args.command == "intercept-handoff":
        if not all([args.task_id, args.from_agent, args.to_agent, args.message]):
            print("Error: --task-id, --from, --to, --message required", file=sys.stderr)
            sys.exit(1)
        result = guard.intercept_handoff(
            args.task_id, args.from_agent, args.to_agent, 
            args.message, args.artifact
        )
    
    elif args.command == "validate-result":
        if not all([args.task_id, args.agent, args.result]):
            print("Error: --task-id, --agent, --result required", file=sys.stderr)
            sys.exit(1)
        try:
            result_data = json.loads(args.result)
        except json.JSONDecodeError:
            print("Error: --result must be valid JSON", file=sys.stderr)
            sys.exit(1)
        result = guard.validate_result(args.task_id, args.agent, result_data)
    
    elif args.command == "health-check":
        if not args.agent:
            print("Error: --agent required", file=sys.stderr)
            sys.exit(1)
        result = guard.check_agent_health(args.agent)
    
    elif args.command == "heartbeat":
        if not args.agent:
            print("Error: --agent required", file=sys.stderr)
            sys.exit(1)
        result = guard.agent_heartbeat(args.agent, args.task_id, args.status)
    
    elif args.command == "supervisor-review":
        if not args.task_id:
            print("Error: --task-id required", file=sys.stderr)
            sys.exit(1)
        result = guard.supervisor_review(args.task_id)
    
    # === BUDGET COMMANDS ===
    
    elif args.command == "budget-init":
        if not args.task_id:
            print("Error: --task-id required", file=sys.stderr)
            sys.exit(1)
        result = guard.init_budget(args.task_id, args.budget, args.description)
    
    elif args.command == "budget-check":
        if not args.task_id:
            print("Error: --task-id required", file=sys.stderr)
            sys.exit(1)
        result = guard.check_budget(args.task_id)
    
    elif args.command == "budget-spend":
        if not args.task_id or not args.tokens or not args.reason:
            print("Error: --task-id, --tokens, --reason required", file=sys.stderr)
            sys.exit(1)
        result = guard.spend_budget(
            args.task_id, args.tokens, args.reason,
            args.agent or "unknown", args.operation
        )
    
    elif args.command == "budget-report":
        if not args.task_id:
            print("Error: --task-id required", file=sys.stderr)
            sys.exit(1)
        result = guard.get_budget_report(args.task_id)
    
    else:
        print(f"Error: Unknown command '{args.command}'", file=sys.stderr)
        sys.exit(1)
    
    # Output
    if args.json:
        print(json.dumps(result, indent=2))
    else:
        _pretty_print(args.command, result)
    
    # Exit code based on result
    if result.get("blocked") or result.get("verdict") == "BLOCKED":
        sys.exit(2)
    elif not result.get("healthy", True) or not result.get("valid", True):
        sys.exit(1)
    sys.exit(0)


def _pretty_print(command: str, result: dict[str, Any]) -> None:
    """Human-readable output."""
    if command == "check-handoff":
        if not result.get("exists"):
            print(f"📋 Task '{result['task_id']}' not found (new task)")
        else:
            remaining = result.get("remaining", 0)
            status_icon = "🟢" if remaining > 1 else "🟡" if remaining == 1 else "🔴"
            print(f"{status_icon} Task: {result['task_id']}")
            print(f"   Handoffs: {result['handoffs']}/{MAX_HANDOFFS_PER_TASK}")
            print(f"   Remaining: {remaining}")
            print(f"   Artifacts: {result['artifacts_produced']}")
            print(f"   Action Ratio: {result.get('action_ratio', 1):.0%}")
    
    elif command == "record-handoff":
        if result.get("blocked"):
            print("🚫 HANDOFF BLOCKED")
            for v in result.get("violations", []):
                print(f"   ❌ {v}")
        else:
            print(f"✅ Handoff #{result['handoff_number']} recorded")
        
        for w in result.get("warnings", []):
            print(f"   ⚠️  {w}")
    
    elif command == "intercept-handoff":
        if result.get("allowed"):
            print(f"✅ HANDOFF ALLOWED: {result['from_agent']} → {result['to_agent']}")
            print(f"   Task: {result['task_id']}")
            print(f"   Tokens spent: {result.get('tokens_spent', 0):,}")
            print(f"   Budget remaining: {result.get('remaining_budget', 0):,}")
            print(f"   Handoff #{result.get('handoff_number', '?')} (remaining: {result.get('remaining_handoffs', 0)})")
            print("   → Proceed with sessions_send")
            
            for w in result.get("warnings", []):
                print(f"   ⚠️  {w}")
        else:
            print(f"🛑 HANDOFF BLOCKED: {result['from_agent']} → {result['to_agent']}")
            print(f"   Task: {result['task_id']}")
            print(f"   Reason: {result.get('reason', 'Unknown')}")
            print(f"   {result.get('message', '')}")
            print("   → Do NOT call sessions_send")
    
    elif command == "validate-result":
        if result.get("valid"):
            print("✅ RESULT VALID")
            print(f"   Task: {result['task_id']}")
            print(f"   Agent: {result['agent_id']}")
            print(f"   → {result['recommendation']}")
        else:
            print("❌ RESULT INVALID")
            for issue in result.get("issues", []):
                print(f"   ❌ {issue}")
            print(f"   → {result['recommendation']}")
        
        for w in result.get("warnings", []):
            print(f"   ⚠️  {w}")
    
    elif command == "health-check":
        if result.get("healthy"):
            print(f"💚 Agent '{result['agent_id']}' is HEALTHY")
            print(f"   Status: {result.get('status')}")
            print(f"   Last seen: {result.get('seconds_since_heartbeat', 0):.0f}s ago")
        else:
            print(f"💔 Agent '{result['agent_id']}' is UNHEALTHY")
            print(f"   Reason: {result.get('reason')}")
            print(f"   → {result.get('recommendation')}")
    
    elif command == "heartbeat":
        print(f"💓 Heartbeat recorded for '{result['agent_id']}'")
    
    elif command == "supervisor-review":
        verdict = result.get("verdict", "UNKNOWN")
        icon = "✅" if verdict == "APPROVED" else "⚠️" if verdict == "WARNING" else "🚫"
        
        print(f"{icon} SUPERVISOR VERDICT: {verdict}")
        print(f"   Task: {result['task_id']}")
        print(f"   Age: {result.get('age_seconds', 0)/60:.1f} minutes")
        print(f"   Handoffs: {result.get('handoffs', 0)}")
        print(f"   Artifacts: {result.get('artifacts', 0)}")
        
        for issue in result.get("issues", []):
            print(f"   ❌ {issue}")
        
        for rec in result.get("recommendations", []):
            print(f"   💡 {rec}")
    
    # === BUDGET COMMANDS ===
    
    elif command == "budget-init":
        if result.get("initialized"):
            print(f"💰 Budget INITIALIZED for '{result['task_id']}'")
            print(f"   Max tokens: {result['max_tokens']:,}")
        else:
            print(f"❌ Budget init FAILED: {result.get('error')}")
    
    elif command == "budget-check":
        if not result.get("initialized"):
            print(f"❌ {result.get('error')}")
        else:
            usage = result.get("usage_percentage", 0)
            status = result.get("status", "UNKNOWN")
            
            if status == "EXHAUSTED":
                icon = "🛑"
            elif status == "WARNING":
                icon = "⚠️"
            else:
                icon = "💰"
            
            print(f"{icon} Budget Status: {status}")
            print(f"   Task: {result['task_id']}")
            print(f"   Used: {result['used_tokens']:,} / {result['max_tokens']:,} tokens")
            print(f"   Remaining: {result['remaining_tokens']:,} tokens")
            print(f"   Usage: {usage:.1f}%")
            
            # Progress bar
            bar_width = 30
            filled = int(bar_width * usage / 100)
            bar = "█" * filled + "░" * (bar_width - filled)
            print(f"   [{bar}]")
            
            if not result.get("can_continue"):
                print("   🚫 Cannot continue - budget exhausted!")
    
    elif command == "budget-spend":
        if result.get("blocked"):
            print("🛑 SAFETY SHUTDOWN TRIGGERED")
            print(f"   {result.get('message')}")
            print(f"   → {result.get('action_required')}")
        elif result.get("allowed"):
            print(f"💸 Spent {result['tokens_spent']:,} tokens")
            print(f"   Reason: {result['reason']}")
            print(f"   Remaining: {result['remaining_tokens']:,} tokens ({100 - result['usage_percentage']:.1f}%)")
            if result.get("warning"):
                print(f"   {result['warning']}")
        else:
            print(f"❌ Spend failed: {result.get('error')}")
    
    elif command == "budget-report":
        if result.get("error"):
            print(f"❌ {result['error']}")
        else:
            summary = result.get("summary", {})
            print(f"📊 Budget Report: {result['task_id']}")
            print(f"   Total Budget: {summary.get('max_tokens', 0):,} tokens")
            print(f"   Used: {summary.get('used_tokens', 0):,} ({summary.get('usage_percentage', 0):.1f}%)")
            print(f"   Remaining: {summary.get('remaining_tokens', 0):,}")
            
            by_op = result.get("by_operation", {})
            if by_op:
                print("\n   By Operation:")
                for op, tokens in sorted(by_op.items(), key=lambda x: -x[1]):
                    print(f"     • {op}: {tokens:,} tokens")
            
            by_agent = result.get("by_agent", {})
            if by_agent:
                print("\n   By Agent:")
                for agent, tokens in sorted(by_agent.items(), key=lambda x: -x[1]):
                    print(f"     • {agent}: {tokens:,} tokens")


if __name__ == "__main__":
    main()


path: scripts/validate_token.py
content: #!/usr/bin/env python3
# SECURITY: This script makes NO network calls and spawns NO subprocesses.
# All I/O is local file operations only:
#   READS:  data[/<env>]/active_grants.json, data[/<env>]/.signing_key
#   WRITES: none
# Imports used: argparse, json, sys, hmac, hashlib, datetime, pathlib, typing
# No imports of: requests, socket, subprocess, urllib, http, ssl, ftplib, smtplib
"""
Validate Grant Token

Check if a permission grant token is valid and not expired.

Usage:
    python validate_token.py TOKEN

Example:
    python validate_token.py grant_a1b2c3d4e5f6
"""

import argparse
import hashlib
import hmac
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

def _resolve_data_dir(env: str = "") -> Path:
    """Return the active data directory, scoped to <env> when set."""
    import re as _re, os as _os
    _env = env or _os.environ.get("NETWORK_AI_ENV", "")
    base = Path(__file__).parent.parent / "data"
    if _env:
        if not _re.match(r'^[a-zA-Z0-9_-]+$', _env):
            raise ValueError(f"Invalid NETWORK_AI_ENV value: {_env!r}")
        return base / _env
    return base

GRANTS_FILE = _resolve_data_dir() / "active_grants.json"


def _load_signing_key() -> "Optional[bytes]":
    """
    Load the local HMAC-SHA256 signing key used by check_permission.py.
    Returns None if the key file does not exist or cannot be read.
    """
    key_file = GRANTS_FILE.parent / ".signing_key"
    if not key_file.exists():
        return None
    try:
        return bytes.fromhex(key_file.read_text().strip())
    except (ValueError, OSError):
        return None


def _verify_grant_sig(grant: "dict[str, Any]") -> "Optional[bool]":
    """
    Verify the HMAC-SHA256 signature stored in a grant record.

    Returns:
      True  — signature present and valid
      False — signature present but invalid (tampered)
      None  — no signature (pre-v5.5.2 token) or key unavailable
    """
    stored_sig = grant.get("_sig")
    if not stored_sig:
        return None  # Backward-compatible: unsigned token from before v5.5.2
    key = _load_signing_key()
    if key is None:
        return None  # Key not present — cannot verify; treat as unverified
    payload = "|".join([
        grant.get("token", ""),
        grant.get("agent_id", ""),
        grant.get("resource_type", ""),
        grant.get("scope") or "",
        grant.get("expires_at", ""),
        grant.get("granted_at", ""),
    ])
    expected = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, stored_sig)


def validate_token(token: str) -> dict[str, Any]:
    """Validate a grant token and return its details."""
    if not GRANTS_FILE.exists():
        return {
            "valid": False,
            "reason": "No grants file found"
        }
    
    try:
        grants = json.loads(GRANTS_FILE.read_text())
    except json.JSONDecodeError:
        return {
            "valid": False,
            "reason": "Invalid grants file"
        }
    
    if token not in grants:
        return {
            "valid": False,
            "reason": "Token not found"
        }
    
    grant = grants[token]

    # Guard against tampered grant records (checks HMAC-SHA256 signature)
    sig_result = _verify_grant_sig(grant)
    if sig_result is False:
        return {
            "valid": False,
            "reason": "Token signature invalid — grant record may have been tampered with",
        }

    # Check expiration
    expires_at = grant.get("expires_at")
    if expires_at:
        try:
            expiry = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
            now = datetime.now(timezone.utc)
            
            if now > expiry:
                return {
                    "valid": False,
                    "reason": "Token has expired",
                    "expired_at": expires_at
                }
        except Exception:
            pass  # unparseable expiry field — treat token as non-expired
    
    return {
        "valid": True,
        "grant": grant,
        "sig_verified": sig_result is True,
    }


def main():
    parser = argparse.ArgumentParser(description="Validate a permission grant token")
    parser.add_argument("token", help="Grant token to validate")
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    parser.add_argument(
        "--env",
        default="",
        help="Target environment (dev|st|sit|qa|sandbox|preprod|prod). Overrides NETWORK_AI_ENV."
    )
    
    args = parser.parse_args()

    # Re-resolve data paths if --env was provided explicitly
    global GRANTS_FILE
    if args.env:
        GRANTS_FILE = _resolve_data_dir(args.env) / "active_grants.json"

    result = validate_token(args.token)
    
    if args.json:
        print(json.dumps(result, indent=2))
    else:
        if result["valid"]:
            grant = result["grant"]
            print("✅ Token is VALID")
            print(f"   Agent: {grant.get('agent_id')}")
            print(f"   Resource: {grant.get('resource_type')}")
            print(f"   Scope: {grant.get('scope', 'N/A')}")
            print(f"   Expires: {grant.get('expires_at')}")
            print(f"   Restrictions: {', '.join(grant.get('restrictions', []))}")
        else:
            print("❌ Token is INVALID")
            print(f"   Reason: {result.get('reason')}")
    
    sys.exit(0 if result["valid"] else 1)


if __name__ == "__main__":
    main()


path: skill.json
content: {
  "name": "SwarmOrchestrator",
  "version": "5.8.9",
  "description": "Local Python orchestration skill: multi-agent workflows via shared blackboard file, permission gating, token budget scripts, and persistent project context. The bundled Python scripts make no network calls and have zero third-party dependencies. The parent repository also contains a TypeScript engine (not included in this skill bundle). Workflow delegations via the host platform's sessions_send may invoke external model APIs.",
  "author": "Network-AI Community",
  "homepage": "https://network-ai.org",
  "repository": "https://github.com/Jovancoding/Network-AI",  "source": "https://github.com/Jovancoding/Network-AI",  "license": "MIT",
  "tags": ["multi-agent", "swarm", "orchestration", "governance", "audit", "permissions", "security", "blackboard", "budget", "local-only"],
  "runtime": "python",
  "entrypoint": "scripts/swarm_guard.py",
  "gateway": "local",
  "install": {
    "description": "No install step required. All Python scripts use standard library only (zero third-party packages). Simply ensure python3 is available and run scripts directly from the scripts/ directory.",
    "python": {
      "requirements": "requirements.txt",
      "requirements_note": "requirements.txt contains zero required packages. All scripts use Python stdlib only.",
      "install_command": "# No install needed",
      "scripts": ["scripts/blackboard.py", "scripts/swarm_guard.py", "scripts/check_permission.py", "scripts/validate_token.py", "scripts/revoke_token.py", "scripts/context_manager.py"],
      "note": "All scripts run locally only. No external network calls. No third-party dependencies."
    }
  },
  "capabilities": {
    "delegate_task": {
      "description": "Delegate a task to a specialized sub-agent within the swarm",
      "parameters": {
        "targetAgent": {
          "type": "string",
          "required": true,
          "description": "The identifier of the target agent to receive the task"
        },
        "taskPayload": {
          "type": "object",
          "required": true,
          "description": "The task context, instructions, and any required data"
        },
        "priority": {
          "type": "string",
          "enum": ["low", "normal", "high", "critical"],
          "default": "normal",
          "description": "Task priority level for queue ordering"
        },
        "timeout": {
          "type": "number",
          "default": 30000,
          "description": "Maximum execution time in milliseconds"
        },
        "requiresAuth": {
          "type": "boolean",
          "default": false,
          "description": "Whether this task requires a permission grant (via check_permission.py)"
        }
      },
      "returns": {
        "type": "object",
        "properties": {
          "taskId": { "type": "string" },
          "status": { "type": "string" },
          "result": { "type": "any" },
          "agentTrace": { "type": "array" }
        }
      }
    },
    "query_swarm_state": {
      "description": "Query the current state of the agent swarm, including active tasks, blackboard contents, and agent availability",
      "parameters": {
        "scope": {
          "type": "string",
          "enum": ["all", "agents", "tasks", "blackboard", "permissions"],
          "default": "all",
          "description": "The scope of state information to retrieve"
        },
        "agentFilter": {
          "type": "array",
          "items": { "type": "string" },
          "description": "Optional list of agent IDs to filter results"
        },
        "includeHistory": {
          "type": "boolean",
          "default": false,
          "description": "Include historical task execution data"
        }
      },
      "returns": {
        "type": "object",
        "properties": {
          "timestamp": { "type": "string" },
          "activeAgents": { "type": "array" },
          "pendingTasks": { "type": "array" },
          "blackboardSnapshot": { "type": "object" },
          "permissionGrants": { "type": "array" }
        }
      }
    },
    "spawn_parallel_agents": {
      "description": "Spawn multiple sub-agents in parallel for complex task decomposition",
      "parameters": {
        "tasks": {
          "type": "array",
          "required": true,
          "items": {
            "type": "object",
            "properties": {
              "agentType": { "type": "string" },
              "taskPayload": { "type": "object" }
            }
          },
          "description": "Array of parallel tasks to execute"
        },
        "synthesisStrategy": {
          "type": "string",
          "enum": ["merge", "vote", "chain", "first-success"],
          "default": "merge",
          "description": "How to combine results from parallel agents"
        }
      },
      "returns": {
        "type": "object",
        "properties": {
          "synthesizedResult": { "type": "any" },
          "individualResults": { "type": "array" },
          "executionMetrics": { "type": "object" }
        }
      }
    },
    "request_permission": {
      "description": "Request permission for sensitive operations via the local permission gating script",
      "parameters": {
        "resourceType": {
          "type": "string",
          "enum": ["DATABASE", "PAYMENTS", "EMAIL", "FILE_EXPORT"],
          "required": true,
          "description": "The type of protected resource being requested"
        },
        "justification": {
          "type": "string",
          "required": true,
          "description": "Reason for requesting access"
        },
        "scope": {
          "type": "string",
          "description": "Specific scope of access needed"
        }
      },
      "returns": {
        "type": "object",
        "properties": {
          "granted": { "type": "boolean" },
          "grantToken": { "type": "string" },
          "expiresAt": { "type": "string" },
          "restrictions": { "type": "array" }
        }
      }
    },
    "update_blackboard": {
      "description": "Write or update entries on the shared blackboard for cross-agent coordination",
      "parameters": {
        "key": {
          "type": "string",
          "required": true,
          "description": "The blackboard entry key"
        },
        "value": {
          "type": "any",
          "required": true,
          "description": "The data to store"
        },
        "ttl": {
          "type": "number",
          "description": "Time-to-live in seconds (optional)"
        }
      },
      "returns": {
        "type": "object",
        "properties": {
          "success": { "type": "boolean" },
          "previousValue": { "type": "any" }
        }
      }
    },
    "inject_context": {
      "description": "Read the persistent project context file and return a formatted markdown block for injection into an agent system prompt. This is Layer 3 (long-lived) memory — architecture decisions, goals, tech stack, milestones, and banned approaches that every agent in the swarm should know.",
      "parameters": {},
      "returns": {
        "type": "object",
        "properties": {
          "contextMarkdown": { "type": "string", "description": "Formatted markdown block ready to prepend to an agent system prompt" },
          "updatedAt": { "type": "string", "description": "ISO timestamp of last context update" }
        }
      }
    },
    "update_context": {
      "description": "Persist a decision, milestone update, stack entry, goal, or banned approach to the project context file (Layer 3 memory). Changes are appended and survive across sessions.",
      "parameters": {
        "section": {
          "type": "string",
          "required": true,
          "enum": ["decisions", "milestones", "stack", "goals", "banned", "project"],
          "description": "The context section to update"
        },
        "add": {
          "type": "any",
          "description": "Item to append (JSON object or plain string, depending on section)"
        },
        "set": {
          "type": "object",
          "description": "Key-value pairs to merge into the section (use for stack and project)"
        },
        "complete": {
          "type": "string",
          "description": "Milestone name to mark completed (milestones section only)"
        }
      },
      "returns": {
        "type": "object",
        "properties": {
          "success": { "type": "boolean" },
          "section": { "type": "string" }
        }
      }
    }
  },
  "permissions": {
    "required": ["local_filesystem", "internal_skill_calls"],
    "optional": ["external_api_access"],
    "permissionGating": {
      "enabled": true,
      "protectedResources": ["DATABASE", "PAYMENTS", "EMAIL", "FILE_EXPORT"],
      "note": "Permission checks are implemented locally via scripts/check_permission.py using weighted scoring (justification 40%, trust 30%, risk 30%). No external auth service."
    }
  },
  "dependencies": {},
  "config": {
    "blackboardPath": "./swarm-blackboard.md",
    "maxParallelAgents": null,
    "maxParallelAgents_default": "Infinity (no hard cap since v4.0.0 — set to a positive integer to enforce a limit)",
    "defaultTimeout": 30000,
    "enableTracing": true
  },
  "env": {},
  "privacy": {
    "audit_log": {
      "path": "data/audit_log.jsonl",
      "scope": "local-only",
      "description": "Append-only JSONL audit log recording operation metadata (agentId, action, timestamp, outcome). Stays in local data/ directory. No data is sent externally by this skill.",
      "contains": ["agentId", "action", "timestamp", "outcome", "resource", "justification (free-text, agent-provided)"],
      "pii_warning": "Justification fields are free-text and may contain user-supplied content. Do not put PII, secrets, or credentials in justification strings. Restrict file permissions on data/ and rotate logs periodically.",
      "does_not_contain": ["API keys", "external endpoints"]
    }
  }
}



path: SUPPLY_CHAIN.md
content: # Supply Chain

What Network-AI installs, what it executes, what it writes to disk, and what it calls
over the network — so operators can make informed trust decisions.

---

## 1. Runtime Dependencies

Network-AI has **one production runtime dependency**:

| Package | Version | Purpose | Network access | Writes to disk |
|---------|---------|---------|---------------|---------------|
| `commander` | ^14.0.3 | CLI argument parsing | None | None |

That's it. The core library, all 29 adapters, the MCP server, and the blackboard are
implemented with Node.js built-ins only.

**BYOC design principle**: Adapters are "Bring Your Own Client". They declare interfaces
and types but import no LLM SDK, no HTTP library, and no vector store at require-time.
The operator passes a pre-constructed client in. This means installing `network-ai` does
**not** install OpenAI, LangChain, AutoGen, or any other framework SDK.

---

## 2. Development Dependencies (not installed in production)

| Package | Version | Purpose |
|---------|---------|---------|
| `typescript` | ^6.0.3 | Compiler |
| `ts-node` | ^10.9.2 | Test runner / local execution |
| `@types/node` | ^25.9.0 | Node.js type declarations |
| `dotenv` | ^17.4.2 | Load `.env` files in tests |
| `openai` | ^6.38.0 | Used in test suites (MCP SSE integration tests) |

`devDependencies` are **not** included in the published npm package.

---

## 3. What Runs at Install Time

| Script | Trigger | Action |
|--------|---------|--------|
| None | `npm install` | No `preinstall` or `postinstall` scripts |

There are no install-time scripts. `npm install network-ai` only downloads and unpacks
the package; no code runs.

The `prepublishOnly` script (`npm run build && npm run test:all`) runs only when the
maintainer publishes a new version. It is not executed on the operator's machine.

---

## 4. What Writes to Disk at Runtime

| Component | What it writes | Where |
|-----------|---------------|-------|
| `LockedBlackboard` | Blackboard state, WAL entries, lock files | `data/` |
| `SecureAuditLogger` | Event log lines (JSONL) | `data/audit_log.jsonl` |
| `EnvironmentManager` | Per-env data directories, backup snapshots | `data/<env>/`, `data/backups/` |
| `check_permission.py` | HMAC signing key (on first run) | `data[/<env>]/.signing_key` |
| `LockedBlackboard` | Filesystem mutex | `data/.lock` |

All writes are confined to the configured data directory. Path traversal is blocked: all
file operations resolve the final path and assert it is within the data root before I/O.

---

## 5. What Calls Over the Network

### Core library: zero outbound network calls

The core library (`index.ts`, `lib/`, `security.ts`) makes **no outbound network calls**.
All network I/O is performed by:

- The operator's BYOC client (passed in by the operator at runtime)
- The MCP transport (`lib/mcp-transport-sse.ts`), which **receives** inbound HTTP/SSE
  connections — it does not initiate outbound connections

### Python helper scripts

The helper scripts (`scripts/blackboard.py`, `scripts/check_permission.py`,
`scripts/validate_token.py`, `scripts/revoke_token.py`) make no network calls. They read
and write local files only.

### Test suite only (not included in the published package)

| File | Network call | Purpose |
|------|-------------|---------|
| `test-phase6.ts` | `localhost:<port>` | MCP SSE integration test (loopback only) |
| `test-a2a.ts` | `localhost:<port>` | A2A protocol integration test (loopback only) |

---

## 6. npm Provenance & SLSA

Every release is published via GitHub Actions CI with:

```yaml
- name: Publish to npm
  run: npm publish --provenance --access public
  env:
    NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
```

The `--provenance` flag publishes a signed SLSA Build Level 2 attestation to the
[Sigstore transparency log](https://search.sigstore.dev/). This attestation links the
published package to the exact Git commit and CI workflow run that built it.

**Verifying provenance**:

```bash
npm audit signatures
# or
npm install network-ai
npx sigstore verify node_modules/network-ai
```

The provenance statement includes:

- Source repository: `https://github.com/Jovancoding/Network-AI`
- Builder: GitHub Actions (`ubuntu-latest`)
- Build workflow: `.github/workflows/ci.yml`
- Git ref: `refs/tags/v<version>`

---

## 7. Signed Git Tags

All release tags are pushed from CI and correspond to a signed commit on `main`. The tag
format is `v<semver>` (e.g. `v5.7.2`).

---

## 8. CodeQL Scanning

Every push and pull request to `main` triggers CodeQL analysis (JavaScript/TypeScript).
Results are published to the GitHub Security tab. Open alerts are tracked and resolved
before each release.

---

## 9. Package Contents (`files` in package.json)

The published npm package includes:

```
dist/          compiled JavaScript + declaration files
bin/           CLI entry points (TypeScript source)
types/         additional .d.ts declarations
scripts/       Python helper scripts
socket.json    MCP socket configuration
README.md
QUICKSTART.md
INTEGRATION_GUIDE.md
SKILL.md
LICENSE
```

Notably **excluded** from the published package:

- `test-*.ts` files
- `data/` directory
- `.github/`
- `node_modules/`
- TypeScript source files (other than `bin/`)

---

## 10. Dependency Update Policy

- Dependabot is enabled for npm (weekly PRs for patch/minor updates).
- All dependency update PRs run the full test suite (3,148 tests) before merge.
- Major version bumps require manual review and a CHANGELOG entry.
- New **runtime** dependencies require explicit approval and an update to this file.


path: THREAT_MODEL.md
content: # Network-AI Threat Model

Version: 5.8.9 — updated with each release that changes a trust boundary or auth mechanism.

---

## 1. Overview

Network-AI is an in-process multi-agent orchestrator. There is no SaaS or cloud-hosted service — the operator installs it and runs it inside their own infrastructure. The package includes an optional MCP SSE server (`bin/mcp-server.ts`) that, when explicitly started by the operator, binds a TCP port and becomes a network-reachable service boundary. This document describes the adversaries we design against, the trust boundaries we enforce, and the explicit non-goals that bound the threat model.

---

## 2. Assets

| Asset | Sensitivity | Description |
|-------|-------------|-------------|
| Blackboard state | High | Shared agent memory; controls agent behaviour |
| Audit log | High | Tamper-evident event record; forensics + compliance |
| Permission tokens | High | Grant-tokens authorise resource access |
| HMAC signing key | Critical | Signs every grant token; compromise = token forgery |
| Agent trust registry | Medium | Trust levels that gate permission scoring |
| Environment configs | Medium | Per-env data directories and promotion chain |
| Budget state | Low | Token/cost accounting; denial-of-service vector |

---

## 3. Adversaries

### 3.1 Unauthenticated Network Caller (primary MCP threat)

**Who**: Any process that can reach the TCP port where `McpSseServer` is bound —
including SSRF on the same host, containers on a shared network, or remote clients when
the operator binds to a non-loopback address.

**Goal**: Invoke MCP tools (`config_set`, `agent_spawn`, `blackboard_write`,
`token_create`, etc.) without credentials.

**Mitigations**:
- `McpSseServer` requires a non-empty `secret`; `listen()` hard-rejects on empty secret (v5.7.2, GHSA-r78r-rwrf-rjwp).
- `_isAuthorized()` fails closed: empty secret → deny (v5.7.2).
- Default bind address is `127.0.0.1`; explicit warning if binding to a non-loopback address.
- All tool invocations are logged to `data/audit_log.jsonl`.

### 3.2 Malicious / Compromised Agent

**Who**: A registered agent that has been compromised or is acting outside its intended role.

**Goal**: Write arbitrary data to the blackboard, escalate its trust level, forge tokens,
or hijack another agent's goal.

**Mitigations**:
- All writes go through `propose → validate → commit`; validators reject schema violations.
- `AuthGuardian` scores each request (justification 40 %, trust 30 %, risk 30 %); low-trust agents are denied high-risk operations.
- Justification hardening rejects prompt-injection patterns, keyword stuffing, repetition, and structural incoherence.
- Agent trust levels are stored in `data/trust_levels.json`; modifications are audit-logged.
- `ComplianceMonitor` detects behavioural anomalies in real time.

### 3.3 Blackboard Poisoning / Context Injection

**Who**: Any caller that can write to the blackboard and wants to influence subsequent agent reads.

**Goal**: Plant malicious instructions that a downstream agent will execute as if they were trusted system data.

**Mitigations**:
- `BlackboardValidator` enforces JSON Schema on all values.
- `InputSanitizer` strips prompt-injection payloads from string values.
- `_validate_context()` in Python scripts detects injection patterns before use.
- SKILL.md warns operators: never store secrets in `data/`; clear `data/` between untrusted projects.

### 3.4 Supply Chain Attacker

**Who**: A dependency author, registry hijacker, or CI pipeline attacker.

**Goal**: Introduce malicious code into network-ai or its dependencies that executes in the operator's environment.

**Mitigations**:
- npm provenance attestation on every release (SLSA Build Level 2).
- Signed commits and version tags.
- Dependabot PRs for dependency updates with CodeQL re-scan on each PR.
- Minimal runtime dependency surface (see `SUPPLY_CHAIN.md`).
- No network calls in core library code (BYOC — bring your own client).

### 3.5 Insider / CLI Operator

**Who**: A developer or CI job with local filesystem access.

**Goal**: Bypass the blackboard's `propose → validate → commit` cycle, forge audit entries, or exfiltrate the signing key.

**Mitigations**:
- Audit log is append-only (no delete API); clearing requires explicit `--yes` flag and is itself logged.
- HMAC signing key is stored at `data/.signing_key`; filesystem permissions are the operator's responsibility.
- All CLI mutations are logged to the audit trail.
- Rate limiting prevents bulk-write abuse from CLI loops.

---

## 4. Trust Boundaries

```
┌─────────────────────────────────────────────────────────────┐
│  Operator environment (filesystem, env vars, CLI)           │
│                                                             │
│  ┌──────────────────────────────────────────────┐           │
│  │  Network-AI process                          │           │
│  │                                              │           │
│  │  Agent code ──► AuthGuardian ──► Blackboard  │           │
│  │       │              │               │       │           │
│  │       │         propose/validate/    │       │           │
│  │       │         commit cycle         │       │           │
│  │       │                              │       │           │
│  │  MCP SSE ──[bearer token]──► McpSseServer    │           │
│  │  (TCP)                                       │           │
│  └──────────────────────────────────────────────┘           │
│                                                             │
│  External callers ──[auth boundary]──► MCP SSE port        │
└─────────────────────────────────────────────────────────────┘
```

**Boundary 1 — MCP network interface**: Bearer token enforced by `McpSseServer`. Empty
secret is a hard startup error. All calls are logged.

**Boundary 2 — Agent → Blackboard**: Mediated by `AuthGuardian`; weighted scoring gate.
Agents cannot write directly to `data/`; they must go through `LockedBlackboard`.

**Boundary 3 — Filesystem**: `data/` directory. The operator owns permissions on this
directory. Network-AI applies path traversal protections (resolved-path boundary checks)
but does not manage OS-level permissions.

**Boundary 4 — Python scripts**: Scripts in `scripts/` run as a child process or
standalone. They validate `NETWORK_AI_ENV` routing and apply HMAC signing to grant
tokens. The signing key lives at `data[/<env>]/.signing_key`.

---

## 5. Explicit Non-Goals

The following are **outside the threat model** — we do not design against them, and
operators must address them at the infrastructure layer:

| Non-goal | Rationale |
|----------|-----------|
| Protection against root/OS-level access | If the attacker owns the process or OS, all file-based protections are void. |
| Encryption of blackboard data in transit between processes on the same host | IPC on localhost is assumed trusted. |
| Protection against a malicious Node.js require() hook | Pre-loaded modules run before any of our code. |
| SLA guarantees for operator-hosted deployments | Network-AI is a library; the operator's SLA is their own. |
| Anti-analysis / obfuscation | Open source; security through obscurity is not a goal. |
| Protection against a compromised npm registry without provenance verification | Operators must verify provenance (`npm audit signatures`). |
| Confidentiality of blackboard values from the process owner | Values are stored as plaintext JSON on the local filesystem. |

---

## 6. Security Controls Summary

| Control | Where | Addresses |
|---------|-------|-----------|
| Fail-closed MCP auth | `lib/mcp-transport-sse.ts` | Adversary 3.1 |
| `propose → validate → commit` | `lib/locked-blackboard.ts` | Adversary 3.2 |
| AuthGuardian weighted scoring | `lib/auth-guardian.ts` | Adversary 3.2 |
| Justification hardening | `lib/auth-guardian.ts` | Adversary 3.2, 3.3 |
| BlackboardValidator schema | `lib/blackboard-validator.ts` | Adversary 3.3 |
| InputSanitizer | `security.ts` | Adversary 3.3 |
| HMAC-signed grant tokens | `scripts/check_permission.py` | Adversary 3.2 |
| npm provenance / SLSA | CI pipeline | Adversary 3.4 |
| Append-only audit log | `security.ts` | Adversary 3.5 |
| ComplianceMonitor | `lib/compliance-monitor.ts` | Adversary 3.2 |
| Rate limiting | `security.ts` | All |
| Path traversal protection | `scripts/blackboard.py` | Adversary 3.5 |

---

## 7. Out-of-scope Findings

Findings in the following categories will be acknowledged but closed as by-design:

- **ASI01** — Agent goal hijack via LLM decomposition: the 3-subtask decomposition boundary is intentional; SKILL.md documents when to enable/disable.
- **ASI03** — Advisory token identity: tokens are explicitly marked advisory; separate platform authentication is required.
- **ASI06** — Persistent context poisoning: `_validate_context()` injection detection is the mitigation; operators must clear `data/` between untrusted projects.
- **ASI07** — Inter-agent communication boundary: all inter-agent messaging is the host platform's responsibility.

---

*For active vulnerabilities, see [SECURITY.md](SECURITY.md) and the [Security Advisories](https://github.com/Jovancoding/Network-AI/security/advisories) page.*


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