From ed07c79b742711b2d40649a1b5365e074e26896f Mon Sep 17 00:00:00 2001 From: Zarithas Date: Fri, 7 Nov 2025 15:44:11 -0500 Subject: [PATCH 01/29] Synching changes to Policytree --- widgets/OTP_generate.py | 9 ++- widgets/policytreewidget.py | 113 ++++++++++++++++++++++-------------- 2 files changed, 73 insertions(+), 49 deletions(-) diff --git a/widgets/OTP_generate.py b/widgets/OTP_generate.py index b35b39d..6aff859 100644 --- a/widgets/OTP_generate.py +++ b/widgets/OTP_generate.py @@ -232,9 +232,8 @@ class OTPGenerator(Widget): api = self.app.api output_lines = [ - "=" * 60, - "OTP GENERATION RESULTS", - "=" * 60, + "Requested OTP Codes:", + "=" * 25, ] otp_dict = {} @@ -250,9 +249,9 @@ class OTPGenerator(Widget): ) for hostname, otp_code in otp_dict.items(): - output_lines.append(f"{hostname:30} | {otp_code}") + output_lines.append(f"{hostname} | {otp_code}") - output_lines.append("=" * 60) + output_lines.append("=" * 25) result_text = "\n".join(output_lines) self._show_result(result_text) diff --git a/widgets/policytreewidget.py b/widgets/policytreewidget.py index 3a0efff..f611d5d 100644 --- a/widgets/policytreewidget.py +++ b/widgets/policytreewidget.py @@ -1,3 +1,4 @@ +from collections import defaultdict import logging from rich.text import Text @@ -17,14 +18,13 @@ class PolicyTreeWidget(Widget): self.policies = policies self.devices = devices self.last_highlighted_node = None + self.leaf_counts = defaultdict(int) def compose(self): - # Left: Policy Tree - policy_tree = Tree("Policies", id="policy_tree") + policy_tree = Tree("", id="policy_tree") # Label set in on_mount policy_tree.styles.width = "2fr" policy_tree.styles.height = "100%" - # Right: Search + Details label = Static("Device Search:") search_box = Input( placeholder="Search policies or devices...", id="tree_search" @@ -40,44 +40,87 @@ class PolicyTreeWidget(Widget): yield details_pane def on_mount(self) -> None: - """Build the tree after mounting.""" + self._precompute_leaf_counts() + + # Update root label with total leaf count + total_leaves = sum( + self.leaf_counts.get(policy.groupid, 0) + for policy in self.policies + if policy.parent == "global-policy-settings" + ) + policy_tree = self.query_one("#policy_tree", Tree) + policy_tree.root.set_label(f"Agents in Policies: ({total_leaves})") + self._build_tree() - def _build_tree(self) -> None: - """Build the policy tree structure.""" + def _precompute_leaf_counts(self): + """Precompute leaf counts for each policy group.""" + device_counts = defaultdict(int) + for device in self.devices: + device_counts[device.groupid] += 1 + + child_map = defaultdict(list) + for policy in self.policies: + child_map[policy.parent].append(policy.groupid) + + def count_leaves(groupid): + count = device_counts[groupid] + for child_id in child_map.get(groupid, []): + count += count_leaves(child_id) + self.leaf_counts[groupid] = count + return count + + for policy in self.policies: + if policy.parent == "global-policy-settings": + count_leaves(policy.groupid) + + def _build_tree(self): policy_tree = self.query_one("#policy_tree", Tree) node_map = {} - # Top-level policies - for policy in self.policies: - if policy.parent == "global-policy-settings": - node = policy_tree.root.add(label=policy.name, data=policy) - node_map[policy.groupid] = node + # Sort top-level policies + top_policies = [ + p for p in self.policies if p.parent == "global-policy-settings" + ] + top_policies.sort( + key=lambda p: self.leaf_counts.get(p.groupid, 0), reverse=True + ) - # Child policies - for policy in self.policies: - parent_id = policy.parent - if parent_id in node_map: - parent_node = node_map[parent_id] - node = parent_node.add(label=policy.name, data=policy) - node_map[policy.groupid] = node + for policy in top_policies: + label = f"{policy.name} ({self.leaf_counts.get(policy.groupid, 0)})" + node = policy_tree.root.add(label=label, data=policy) + node_map[policy.groupid] = node - # Devices under policies + # Sort and add child policies + children_by_parent = defaultdict(list) + for policy in self.policies: + if policy.parent != "global-policy-settings": + children_by_parent[policy.parent].append(policy) + + for parent_id, children in children_by_parent.items(): + children.sort( + key=lambda p: self.leaf_counts.get(p.groupid, 0), reverse=True + ) + parent_node = node_map.get(parent_id) + if parent_node: + for policy in children: + label = f"{policy.name} ({self.leaf_counts.get(policy.groupid, 0)})" + node = parent_node.add(label=label, data=policy) + node_map[policy.groupid] = node + + # Add devices (leaf nodes) for device in self.devices: group_id = device.groupid - if group_id in node_map: - parent_node = node_map[group_id] - label = device.hostname - parent_node.add(label=label, data=device) + parent_node = node_map.get(group_id) + if parent_node: + parent_node.add(label=device.hostname, data=device) def _collect_tree_nodes(self, node, all_nodes): - """Helper to recursively collect all nodes from a tree.""" all_nodes.append(node) for child in node.children: self._collect_tree_nodes(child, all_nodes) def _remove_match_selector(self): - """Safely remove match selector widgets.""" try: existing = self.query("#match_selector") for widget in existing: @@ -87,20 +130,16 @@ class PolicyTreeWidget(Widget): logger.debug("Failed to remove match_selector: %s", exc) def on_tree_node_selected(self, message: Tree.NodeSelected) -> None: - """Handle tree node selection.""" node = message.node data = node.data details_pane = self.query_one("#details_pane", Static) - # Reset previous highlight if self.last_highlighted_node is not None: original_label = str(self.last_highlighted_node.label).strip() - # Remove any styling if isinstance(self.last_highlighted_node.label, Text): original_label = self.last_highlighted_node.label.plain self.last_highlighted_node.set_label(original_label) - # Apply highlight to current node label_text = str(node.label).strip() if isinstance(node.label, Text): label_text = node.label.plain @@ -108,9 +147,7 @@ class PolicyTreeWidget(Widget): node.set_label(highlighted_label) self.last_highlighted_node = node - # Update details pane if data: - # Work with dataclass objects using __dict__ details = "\n".join( f"{key}: {value}" for key, value in data.__dict__.items() ) @@ -118,12 +155,9 @@ class PolicyTreeWidget(Widget): details = f"Selected: {node.label}" details_pane.update(details) - # Stop event from bubbling message.stop() def on_input_submitted(self, message: Input.Submitted) -> None: - """Handle search input submission.""" - # Remove existing match selector FIRST self._remove_match_selector() query = message.value.strip().lower() @@ -138,7 +172,6 @@ class PolicyTreeWidget(Widget): label_text = str(node.label).lower() label_to_node[label_text] = node if node.data: - # Use __dict__ for dataclass objects data_dict = ( node.data.__dict__ if hasattr(node.data, "__dict__") else node.data ) @@ -146,18 +179,15 @@ class PolicyTreeWidget(Widget): if isinstance(value, str): label_to_node[value.lower()] = node - # Wildcard-style substring match matches = sorted([label for label in label_to_node if query in label]) if matches: - # Try to reuse existing match_selector or create new one try: option_list = self.query_one("#match_selector", OptionList) option_list.clear_options() - option_list.display = True # Ensure it's visible + option_list.display = True except: option_list = OptionList(id="match_selector") - # Mount to the details pane's parent (the Vertical container) details_pane.parent.mount(option_list) for label in matches: @@ -165,17 +195,14 @@ class PolicyTreeWidget(Widget): details_pane.update(f"Found {len(matches)} matches. Select one below.") else: - # Hide or remove the match_selector when no matches self._remove_match_selector() details_pane.update("No matches found.") def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - """Handle selection from search results.""" selected_id = event.option.id.replace("match_", "") tree = self.query_one("#policy_tree", Tree) details_pane = self.query_one("#details_pane", Static) - # Find the node all_nodes = [] self._collect_tree_nodes(tree.root, all_nodes) @@ -183,7 +210,6 @@ class PolicyTreeWidget(Widget): match_node = label_to_node.get(selected_id.lower()) if match_node: - # Expand path (original working logic) node = match_node path = [] while node: @@ -198,7 +224,6 @@ class PolicyTreeWidget(Widget): match_node.set_label(Text(str(match_node.label), style="reverse bold")) details_pane.update(f"Selected: {match_node.label}") - # Remove the match_selector after selection try: option_list = self.query_one("#match_selector", OptionList) option_list.remove() From 07be104cae7ed004775ba2e148c3a56060b1e01a Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Fri, 7 Nov 2025 16:48:25 -0500 Subject: [PATCH 02/29] Adjusted API Timeout --- airlock_libs/src/services.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index 8870d5c..cfb5345 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -205,7 +205,7 @@ fn build_client(py: Python<'_>, py_self: &Py) -> Client { Client::builder() .danger_accept_invalid_certs(true) .default_headers(header_map) - .timeout(std::time::Duration::from_secs(30)) + .timeout(std::time::Duration::from_secs(120)) .build() .unwrap() } From ef20efeee3a30e767fbe417fc31f565d212a6577 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Fri, 7 Nov 2025 16:48:42 -0500 Subject: [PATCH 03/29] Adjusted API Timeout --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index a3d9b52..beceb5a 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -17,7 +17,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "2.0.0" +version = "2.0.1" dependencies = [ "chrono", "indicatif", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 3a853d9..464e271 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "2.0.0" +version = "2.0.1" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index e7cd8aa..37f7b97 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "2.0.0" +version = "2.0.1" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } From 2601ec349b507b0a135439b6dcdccc3f6f3117ce Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Fri, 7 Nov 2025 17:33:13 -0500 Subject: [PATCH 04/29] Updated Requirements for 2.0.1 of Airlock Libs --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 65ee4c7..4585b52 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==2.0.0 \ No newline at end of file +airlock_libs==2.0.1 \ No newline at end of file From 392060e9faf93edf74677bcf8fc711a70a2d52f3 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Fri, 7 Nov 2025 17:33:44 -0500 Subject: [PATCH 05/29] Updated Requirements for 2.0.1 of Airlock Libs --- airlock_libs/airlock_libs.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airlock_libs/airlock_libs.pyi b/airlock_libs/airlock_libs.pyi index 137dc92..84bee85 100644 --- a/airlock_libs/airlock_libs.pyi +++ b/airlock_libs/airlock_libs.pyi @@ -30,7 +30,7 @@ def history_logging( checkpoint_number: str, policy_names: str, ) -> List[Dict[str, Any]]: - """ + """ Query execution history logs from the Airlock API. Parameters From 5510d22cbd212d8cd93b7aa8e7573ced9a52986b Mon Sep 17 00:00:00 2001 From: Zarithas Date: Sun, 9 Nov 2025 21:06:07 -0500 Subject: [PATCH 06/29] Checking WIP Agent Movement Workflow --- AirlockTools_Server.py | 93 ---- IRT_icon_32-512.ico | Bin 21181 -> 0 bytes Server/scheduler_async.py | 215 --------- README.md => docs/README.md | 0 flows/localApproval.py | 455 ++++++++----------- screens/moveagentworkflowscreen.py | 61 +++ screens/policyselectorscreen.py | 91 ++++ utils/selector.py | 6 +- utils/tui.py | 60 +++ utils/utils.py | 38 -- widgets/OTP_generate.py | 13 +- widgets/agentmoveoperations.py | 704 +++++++++++++++++++++++++++++ widgets/multiagentselector.py | 12 +- widgets/policyselector.py | 505 +++++++++++++++++++++ widgets/resultsdisplay.py | 178 ++++++++ widgets/retro_terminal_theme.py | 38 ++ widgets/themeselector.py | 25 +- 17 files changed, 1868 insertions(+), 626 deletions(-) delete mode 100644 AirlockTools_Server.py delete mode 100644 IRT_icon_32-512.ico delete mode 100644 Server/scheduler_async.py rename README.md => docs/README.md (100%) create mode 100644 screens/moveagentworkflowscreen.py create mode 100644 screens/policyselectorscreen.py create mode 100644 widgets/agentmoveoperations.py create mode 100644 widgets/policyselector.py create mode 100644 widgets/resultsdisplay.py create mode 100644 widgets/retro_terminal_theme.py diff --git a/AirlockTools_Server.py b/AirlockTools_Server.py deleted file mode 100644 index 183635c..0000000 --- a/AirlockTools_Server.py +++ /dev/null @@ -1,93 +0,0 @@ -# Copyright (C) 2025 James Brotosky, Brandon Wickline -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -# TODO Add CSV injection prevention -# TODO Continue OTP and Local approval rewrites -# TODO Explore pywin32 -# TODO Fix Requirements.txt -# TODO Create Generic system_config.json for gitea - - -import logging -import os - -import dotenv -import urllib3 - -import flows.localApproval as la -from Server.scheduler_async import ( - recurring_job, - register_function, - reload_jobs, - start_scheduler, -) -from services.API import AirlockAPIWrapper -from services.policyhandler import updateAuditPoliciesFromEnforcementPolices -from services.security import getAPI -from utils.setup import setup - -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - - -def main(): - - # Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored - - working_dir = setup() - - logger = logging.getLogger(__name__) - - dotenv.load_dotenv(dotenv_path=working_dir / ".env") - - try: - url = os.getenv("URL") - username = os.getenv("USERNAME") - - if not url: - raise ValueError("Missing URL in environment variables.") - if not username: - raise ValueError("Missing USERNAME in environment variables.") - - logger.debug(f"Retrieved URL: {url}") - logger.debug(f"Retrieved Username: {username}") - - except ValueError as e: - logger.error(f"Configuration error: {e}", exc_info=True) - raise - - api = AirlockAPIWrapper( - base_url=str(os.getenv("URL")), - api_key=getAPI(username, "AirlockTools"), - ) - - logger.info("Running non-interactively to start monitoring Airlock Changes") - - register_function("monitorLA", la.scheduleAddingLAHashes) - register_function("updateAuditPolicies", updateAuditPoliciesFromEnforcementPolices) - - if not os.path.exists("scheduling\\jobs.json"): - recurring_job("monitorLA", "monitorLA", interval=50, unit="seconds", args=[api]) - recurring_job( - "updateAuditPolicies", "updateAudit", interval=5, unit="minutes", args=[api] - ) - else: - reload_jobs() - - start_scheduler() - - -if __name__ == "__main__": - main() diff --git a/IRT_icon_32-512.ico b/IRT_icon_32-512.ico deleted file mode 100644 index 103b72ef73c7c58a81ad42b075aad1a626c2402f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21181 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 a82. - -import asyncio -import json -import logging -import os -from typing import Any, Callable, Dict, List - -logger = logging.getLogger(__name__) - -# Registry of functions that can be scheduled -FUNCTION_MAP: Dict[str, Callable] = {} - -# Dictionary to manually track scheduled jobs by ID -scheduled_jobs: Dict[str, asyncio.TimerHandle] = {} - -# Path to the JSON file for job persistence TODO - pin this to the correct place -JOBS_FILE = os.path.join(os.getcwd(), "jobs.json") - - -def register_function(name: str, func: Callable): - """ - Register a function so it can be called by name later. - Example: - register_function("say_hello", say_hello) - """ - FUNCTION_MAP[name] = func - - -def load_jobs() -> List[Dict[str, Any]]: - """ - Load jobs from the JSON file, or return [] if none exist. - """ - if not os.path.exists(JOBS_FILE): - return [] - with open(JOBS_FILE, "r") as f: - return json.load(f) - - -def save_jobs(jobs: List[Dict[str, Any]]): - """ - Save jobs to the JSON file (overwrite). - """ - with open(JOBS_FILE, "w") as f: - json.dump(jobs, f, indent=4) - - -def cancel_job(job_id: str): - """ - Cancel a scheduled job by ID and remove it from the registry and persistence. - """ - handle = scheduled_jobs.pop(job_id, None) - if handle: - handle.cancel() - logger.info(f"Cancelled job '{job_id}'") - - jobs = [j for j in load_jobs() if j.get("id") != job_id] - save_jobs(jobs) - - -def run_once_job( - job_id: str, - func_name: str, - delay_seconds: float, - args=None, - kwargs=None, - persist=True, -): - """ - Schedule a job to run once after a delay (in seconds). - """ - args = args or [] - kwargs = kwargs or {} - - def job_wrapper(): - func = FUNCTION_MAP.get(func_name) - if func is None: - logger.error(f"Function '{func_name}' is not registered.") - return - func(*args, **kwargs) - cancel_job(job_id) - - loop = asyncio.get_event_loop() - handle = loop.call_later(delay_seconds, job_wrapper) - scheduled_jobs[job_id] = handle - - if persist: - jobs = [j for j in load_jobs() if j.get("id") != job_id] - jobs.append( - { - "id": job_id, - "type": "once", - "delay": delay_seconds, - "function": func_name, - "args": args, - "kwargs": kwargs, - } - ) - save_jobs(jobs) - logger.info( - f"Scheduled one-time job '{job_id}' to run in {delay_seconds} seconds." - ) - - -def recurring_job( - job_id: str, func_name: str, interval: float, args=None, kwargs=None, persist=True -): - """ - Schedule a recurring job. - """ - args = args or [] - kwargs = kwargs or {} - - def job_wrapper(): - func = FUNCTION_MAP.get(func_name) - if func is None: - logger.error(f"Function '{func_name}' is not registered.") - return - func(*args, **kwargs) - # Reschedule the job - handle = asyncio.get_event_loop().call_later(interval, job_wrapper) - scheduled_jobs[job_id] = handle - - cancel_job(job_id) - handle = asyncio.get_event_loop().call_later(interval, job_wrapper) - scheduled_jobs[job_id] = handle - - if persist: - jobs = [j for j in load_jobs() if j.get("id") != job_id] - jobs.append( - { - "id": job_id, - "type": "recurring", - "interval": interval, - "function": func_name, - "args": args, - "kwargs": kwargs, - } - ) - save_jobs(jobs) - logger.info(f"Scheduled recurring job '{job_id}' every {interval} seconds.") - - -def reload_jobs(): - """ - Reload jobs from JSON and reschedule them. - """ - jobs = load_jobs() - for job in jobs: - if job["type"] == "once": - run_once_job( - job["id"], - job["function"], - job["delay"], - job.get("args"), - job.get("kwargs"), - persist=False, - ) - elif job["type"] == "recurring": - recurring_job( - job["id"], - job["function"], - job["interval"], - job.get("args"), - job.get("kwargs"), - persist=False, - ) - - -async def start_scheduler(): - """ - Start the asynchronous scheduler loop. - - This function is a placeholder to keep the event loop alive. - Jobs are scheduled using asyncio.call_later and do not require polling. - """ - try: - await asyncio.Event().wait() - except asyncio.CancelledError: - logger.critical("Scheduler stopped.") - - """ - Start the asynchronous scheduler loop. - - This function is a placeholder for compatibility. Since we use asyncio.call_later, - jobs are scheduled directly on the event loop and no polling is required. - - Usage: - # In an async app (e.g., Textual) - asyncio.create_task(start_scheduler()) - - # Or in a standalone script - async def main(): - await start_scheduler() - - asyncio.run(main()) - """ - try: - while True: - await asyncio.sleep(3600) # Sleep indefinitely; jobs run via call_later - except asyncio.CancelledError: - logger.critical("Scheduler stopped.") diff --git a/README.md b/docs/README.md similarity index 100% rename from README.md rename to docs/README.md diff --git a/flows/localApproval.py b/flows/localApproval.py index 5cc69a2..da89c8c 100644 --- a/flows/localApproval.py +++ b/flows/localApproval.py @@ -1,311 +1,242 @@ -# Copyright (C) 2025 James Brotosky, Brandon Wickline -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . +""" +This module handles the creation of local approval requests. +""" - -import datetime import logging import os -import re import time - -import dotenv -import numpy as np -import pandas as pd +from typing import List, Optional from models.agent import Agent -from services.agenthandler import findAllAgents, moveAgentToRelatedPolicy, selectAgents +from services.agenthandler import moveAgentToRelatedPolicy, selectAgents from services.API import AirlockAPIWrapper -from utils.configmanager import get_protected_json, load_env, load_env_json -from utils.setup import get_base_directory +from utils.configmanager import get_protected_json from utils.utils import colorText, get_sanitized_input logger = logging.getLogger(__name__) -dotenv.load_dotenv() +class LocalApprovalRequestor: + """Handles creation of local approval requests in Loxide.""" -def getLocalApprovals(api: AirlockAPIWrapper): - base_dir = get_base_directory - result = api.otp_find_awaiting() - local_approval = pd.DataFrame(result["response"]["otpusage"]) - if os.path.exists(f"{base_dir}\\cache\\newest_local_approval.parquet"): - previous_run = pd.read_parquet( - f"{base_dir}\\cache\\newest_local_approval.parquet" - ) - previous_run.to_parquet( - f"{base_dir}\\cache\\last_local_approval.parquet", index=False - ) - os.remove(f"{base_dir}\\cache\\newest_local_approval.parquet") + def __init__(self, api: AirlockAPIWrapper, username: str = None): + """ + Initialize the local approval requestor. - # Only keep rows presumably created by the generate local approval function - local_approval = local_approval[ - local_approval["purpose"].str.startswith("🎫 Local Approval 🎫") - ] - - local_approval["batchid"] = local_approval["purpose"].apply( - lambda x: (match := re.search(r"batch:(\S+)", str(x))) and match.group(1) - ) - - if not local_approval.empty: - local_approval.to_parquet( - f"{base_dir}\\cache\\newest_local_approval.parquet", index=False + Args: + api: AirlockAPIWrapper instance + username: Username creating the approvals (for tracking) + """ + self.api = api + self.policy_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") + self.username = ( + username or os.getenv("USERNAME") or os.getenv("USER") or "unknown" ) - return local_approval + def create_local_approval( + self, agent_id: str, duration_minutes: int, batch_id: Optional[int] = None + ) -> bool: + """ + Create a single local approval request. + Args: + agent_id: Agent ID to create approval for + duration_minutes: Duration of approval in minutes + batch_id: Optional batch identifier (defaults to timestamp) -def scheduleAddingLAHashes(api: AirlockAPIWrapper): + Returns: + True if successful, False otherwise + """ + if batch_id is None: + batch_id = int(time.time()) - policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") - bad_publisher_list = load_env_json("BAD_PUBLISHER", "[]") - pups = load_env_json("PUPS", "[]") - threat_tolerance_constant = load_env("VT_THREAT_TOLERANCE", cast_type=int) + purpose = ( + f"🎫 Local Approval 🎫 - {duration_minutes} mins - " + f"batch:{batch_id} Client:{agent_id} User:{self.username}" + ) - try: - register_function("add_hash", returnFromLocalApproval) - register_function("move_device", moveAgentToRelatedPolicy) - except Exception as e: - logger.warning(f"Failed to register functions: {e}") - return - - try: - approvals_df = getNewLocalApprovals(api) - if approvals_df.empty: - logger.debug("No new local approvals found. Nothing to schedule.") - return - batches = approvals_df.groupby("batchid") - except Exception as e: - logger.warning(f"Failed to retrieve or group local approvals: {e}") - return - - for batchid, batch_df in batches: try: - duration_minutes = int(batch_df["duration"].iloc[0]) - start_time = datetime.datetime.now() - run_time = start_time + datetime.timedelta(minutes=duration_minutes) - early_time = start_time + datetime.timedelta( - minutes=np.floor(duration_minutes * 0.95) + self.api.otp_generate(agent_id, duration_minutes, purpose) + logger.info( + f"Generated local approval for {agent_id}, batch {batch_id}, by {self.username}" ) - - early_timestamp = early_time.timestamp() - run_timestamp = run_time.timestamp() - - # Schedule add_hash job - try: - run_once_job( - f"add_hash_{batchid}", - "add_hash", - early_timestamp, - [ - api, - batch_df, - policy_relationship_map, - bad_publisher_list, - pups, - threat_tolerance_constant, - ], - None, - ) - logger.debug(f"Scheduled add_hash for batch {batchid} at {early_time}") - except Exception: - logger.debug("Failed to schedule add_hash for batch {batchid}: {e}") - - # Schedule move_device jobs - devices = batch_df["agentid"].drop_duplicates().tolist() - agents = [] - - for device in devices: - rows = api.agent_find_by_hostname(device).iterrows() - agents += [Agent(**row["data"]) for _, row in rows] - - for agent in agents: - try: - run_once_job( - f"move_device_{agent.hostame}_{batchid}", - "move_device", - run_timestamp, - [api, agent, policy_relationship_map], - "enforcement", - ) - - print( - f"Scheduled move_device for device {agent.hostname} in batch {batchid} at {run_time}" - ) - except Exception as e: - print( - f"Failed to schedule move_device for device {agent.hostname} in batch {batchid}: {e}" - ) - + return True except Exception as e: - logger.warning(f"Failed to process batch {batchid}: {e}") + logger.error(f"Failed to generate local approval for {agent_id}: {e}") + return False + def move_agent_to_audit(self, agent: Agent) -> bool: + """ + Move an agent to its corresponding audit policy. -def returnFromLocalApproval( - api, - device_df, - policy_relationship_map, - bad_publisher_list, - pups, - threat_tolerance_constant, -): - """ - # Get unique policy names from device list - policies_in_devicelist = sorted(device_df['policy_name'].unique().tolist()) + Args: + agent: Agent object to move - # Create inverse map to go from Audit to Enforcement - inverse_map = {v: k for k, v in policy_relationship_map.items()} - - # Fetch all policies - all_policies = [Policy(row['groupid'], row['hidden'], row['name'], row['parent']) for _, row in api.policy_find_all().iterrows()] - - # Define policy types - policy_types = [1, 2, 6, 7] - - #TODO finish logic for adding hashes - """ - working_dir = load_env("WORKING_DIR") - policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") - bad_publisher_list = load_env_json("BAD_PUBLISHER", "[]") - pups = load_env_json("PUPS", "[]") - threat_tolerance_constant = load_env("VT_THREAT_TOLERANCE") - print( - f"{working_dir}, {policy_relationship_map}, {bad_publisher_list}, {pups}, {threat_tolerance_constant}" - ) - - -def moveToLocalApproval(api: AirlockAPIWrapper): - possible_durations = [15, 60, 360, 1440, 10080] - duration_selected = None - - print(colorText("Please select a duration:", "white")) - for i, option in enumerate(possible_durations, start=1): - print(f"{i}. {option}") - - try: - - choice = int(get_sanitized_input("Enter the number of your choice:")) - if 1 <= choice <= len(possible_durations): - duration_selected = possible_durations[choice - 1] - print(colorText(f"You selected: {duration_selected}", "yellow")) - logger.debug(f"You selected: {duration_selected}") - else: - print(colorText("❌ Invalid choice.", "red")) - logger.debug("Invalid Input") - return - except ValueError: - print(colorText("❌ Invalid input. Please enter a number.", "red")) - logger.debug("Invalid Input") - return - - agents = selectAgents(api) - batch = int(time.time()) - - if not agents: - print(colorText("❌ No agents found or error retrieving agents.", "red")) - logger.debug("No agents found or error retrieving agents") - return - - for agent in agents: + Returns: + True if successful, False otherwise + """ try: - addLocalApproval(api, batch, duration_selected, agent.agentid) - moveAgentToRelatedPolicy(api, agent, "audit") + moveAgentToRelatedPolicy(self.api, agent, "audit") + logger.info(f"Moved {agent.hostname} to audit policy") + return True except Exception as e: - print(colorText(f"❌ Error processing agent {agent.hostname}: {e}", "red")) + logger.error(f"Failed to move {agent.hostname} to audit: {e}") + return False + def create_local_approval_batch( + self, + agents: List[Agent], + duration_minutes: int, + ) -> tuple[int, int, int]: + """ + Create local approvals for multiple agents and move them to audit. -def addLocalApproval(api: AirlockAPIWrapper, batchid, duration_selected, agentid): + Args: + agents: List of Agent objects + duration_minutes: Duration of approval in minutes + db_path: Optional path to database for history tracking - purpose = f"🎫 Local Approval 🎫 - {duration_selected} mins - batch:{batchid} Client:{agentid}" - api.otp_generate(agentid, duration_selected, purpose) + Returns: + Tuple of (batch_id, success_count, failure_count) + """ + batch_id = int(time.time()) + success_count = 0 + failure_count = 0 + print(colorText(f"\nπŸ“¦ Processing batch {batch_id}...", "cyan")) + print(colorText(f"πŸ‘€ Requested by: {self.username}", "cyan")) + print( + colorText(f"πŸ“Š Moving {len(agents)} agent(s) to local approval\n", "cyan") + ) -def monitorAuditStatus(api: AirlockAPIWrapper): - current_agents = findAllAgents(api) - last_agents = [] - if not last_agents: - last_agents = current_agents - policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") + for agent in agents: + try: + # Create local approval + approval_success = self.create_local_approval( + agent.agentid, duration_minutes, batch_id + ) - # Reverse map for audit β†’ enforcement - reverse_policy_map = {v: k for k, v in policy_relationship_map.items()} - known_transitions = set(policy_relationship_map.items()) | set( - reverse_policy_map.items() - ) + if not approval_success: + raise Exception("Failed to create local approval") - # Index last_agents by hostname for quick lookup - last_agent_map = {agent.hostname: agent for agent in last_agents} + # Move to audit policy + move_success = self.move_agent_to_audit(agent) - # Result buckets - newly_added = [] - same_policy = [] - moved_to_audit = [] - moved_to_enforcement = [] - unusual_move = [] + if not move_success: + raise Exception("Failed to move to audit policy") - for current in current_agents: - previous = last_agent_map.get(current.hostname) + print(colorText(f"βœ“ {agent.hostname}", "green")) + success_count += 1 - if not previous: - newly_added.append(current) - continue + except Exception as e: + print(colorText(f"βœ— {agent.hostname}: {e}", "red")) + logger.error(f"Error processing agent {agent.hostname}: {e}") + failure_count += 1 - if current.groupid == previous.groupid: - same_policy.append(current) - elif (previous.groupid, current.groupid) in known_transitions: - moved_to_audit.append(current) - elif (current.groupid, previous.groupid) in known_transitions: - moved_to_enforcement.append(current) - else: - unusual_move.append(current) + return batch_id, success_count, failure_count - # Return all five DataFrames - return newly_added, same_policy, moved_to_audit, moved_to_enforcement, unusual_move + def interactive_local_approval(self): + """ + Interactive workflow to create local approvals for selected agents. + This prompts the user to select a duration and agents, then creates + the local approvals and moves agents to audit policies. + """ + # Duration options in minutes + duration_options = [ + (15, "15 minutes"), + (60, "1 hour"), + (360, "6 hours"), + (1440, "1 day"), + (10080, "1 week"), + ] -def getNewLocalApprovals(api: AirlockAPIWrapper): + # Display duration options + print(colorText("\n⏱️ Select Local Approval Duration:", "white")) + print(colorText("=" * 50, "white")) - working_dir = load_env("WORKING_DIR") - current_la = getLocalApprovals(api) + for i, (minutes, label) in enumerate(duration_options, start=1): + print(f" {i}. {label} ({minutes} minutes)") - # Load old approval list - old_la_path = f"{working_dir}\\Scheduling\\last_local_approval.parquet" - if os.path.exists(old_la_path): - old_la = pd.read_parquet(old_la_path) - else: - old_la = pd.DataFrame(columns=current_la.columns) + print(colorText("=" * 50, "white")) - # Create composite keys - current_la["key"] = ( - current_la["clientid"].astype(str) + "_" + current_la["granted"].astype(str) - ) - old_la["key"] = old_la["clientid"].astype(str) + "_" + old_la["granted"].astype(str) + # Get user selection + try: + choice = int(get_sanitized_input("\nEnter the number of your choice: ")) - # Find new entries - new_entries = current_la[~current_la["key"].isin(old_la["key"])] + if 1 <= choice <= len(duration_options): + duration_minutes, duration_label = duration_options[choice - 1] + print(colorText(f"βœ“ Selected: {duration_label}", "green")) + logger.info(f"User selected duration: {duration_minutes} minutes") + else: + print(colorText("❌ Invalid choice.", "red")) + logger.warning("Invalid duration choice") + return - # Convert 'granted' to datetime and filter by last 10 minutes - new_entries["granted"] = pd.to_datetime( - new_entries["granted"], utc=True, errors="coerce" - ) - ten_minutes_ago = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( - minutes=10 - ) - recent_entries = new_entries[new_entries["granted"] > ten_minutes_ago] + except ValueError: + print(colorText("❌ Invalid input. Please enter a number.", "red")) + logger.warning("Invalid input for duration selection") + return - # Save current approvals for next run - current_la.drop(columns=["key"], inplace=True) - current_la.to_parquet(old_la_path, index=False) + # Select agents + print(colorText("\n🎯 Select Agents for Local Approval:", "white")) + agents = selectAgents(self.api) - return recent_entries + if not agents: + print(colorText("❌ No agents found or error retrieving agents.", "red")) + logger.warning("No agents selected or error retrieving agents") + return + + # Confirm with user + print(colorText("\nπŸ“‹ Summary:", "cyan")) + print(colorText(f" Duration: {duration_label}", "white")) + print(colorText(f" Agents: {len(agents)}", "white")) + + confirm = get_sanitized_input("\nProceed? (y/n): ").lower() + + if confirm != "y": + print(colorText("❌ Operation cancelled.", "yellow")) + return + + # Process the batch + batch_id, success_count, failure_count = self.create_local_approval_batch( + agents, duration_minutes + ) + + # Display summary + self._display_summary(batch_id, duration_label, success_count, failure_count) + + def _display_summary( + self, batch_id: int, duration_label: str, success_count: int, failure_count: int + ): + """ + Display operation summary. + + Args: + batch_id: Batch identifier + duration_label: Human-readable duration + success_count: Number of successful operations + failure_count: Number of failed operations + """ + print(colorText(f"\n{'=' * 60}", "white")) + print(colorText("πŸ“Š Local Approval Summary", "cyan")) + print(colorText("=" * 60, "white")) + + print(colorText(f"βœ“ Successfully processed: {success_count}", "green")) + + if failure_count > 0: + print(colorText(f"βœ— Failed: {failure_count}", "red")) + + print(colorText(f"\nπŸ“¦ Batch ID: {batch_id}", "cyan")) + print(colorText(f"⏱️ Duration: {duration_label}", "cyan")) + + print(colorText("=" * 60, "white")) + print(colorText("\nπŸ’‘ Next Steps:", "yellow")) + print(colorText(" β€’ Agents have been moved to audit policies", "white")) + print(colorText(" β€’ Local approvals are active", "white")) + print( + colorText( + f" β€’ Agents will return to enforcement after {duration_label}", "white" + ) + ) + print(colorText("=" * 60 + "\n", "white")) diff --git a/screens/moveagentworkflowscreen.py b/screens/moveagentworkflowscreen.py new file mode 100644 index 0000000..7199f55 --- /dev/null +++ b/screens/moveagentworkflowscreen.py @@ -0,0 +1,61 @@ +from typing import List + +from textual.app import ComposeResult +from textual.screen import Screen + +from models.agent import Agent +from widgets.agentmoveoperations import AgentMoveOperations +from widgets.multiagentselector import MultiAgentSelector +from widgets.resultsdisplay import ResultsDisplay + + +class MoveAgentWorkflowScreen(Screen): + """Screen that handles the agent movement workflow.""" + + def __init__(self, all_agents: List[Agent]): + super().__init__() + self.all_agents = all_agents + self.selected_agents = None + + def compose(self) -> ComposeResult: + """Start with the multi-agent selector.""" + yield MultiAgentSelector(self.all_agents) + + def on_multi_agent_selector_agents_selected( + self, message: MultiAgentSelector.AgentsSelected + ) -> None: + """Handle selected agents - switch to operations screen.""" + self.selected_agents = message.selected_agents + + # Remove the MultiAgentSelector + selector = self.query_one(MultiAgentSelector) + selector.remove() + + # Mount the AgentMoveOperations with the selected Agent objects + self.mount(AgentMoveOperations(self.selected_agents)) + + def on_agent_move_operations_operation_complete( + self, message: AgentMoveOperations.OperationComplete + ) -> None: + """Handle completion of move operation - transition to results screen.""" + # Format successful results + success_lines = [] + for agent, result in message.successful: + success_lines.append(f"βœ“ {agent.hostname}") + + # Format unsuccessful results + failure_lines = [] + for agent, error in message.unsuccessful: + failure_lines.append(f"βœ— {agent.hostname}: {error}") + + successful_text = "\n".join(success_lines) if success_lines else "(none)" + unsuccessful_text = "\n".join(failure_lines) if failure_lines else "(none)" + + # Remove the operations widget + ops_widget = self.query_one(AgentMoveOperations) + ops_widget.remove() + + # Mount the results display + self.mount( + ResultsDisplay(message.operation, successful_text, unsuccessful_text) + ) diff --git a/screens/policyselectorscreen.py b/screens/policyselectorscreen.py new file mode 100644 index 0000000..5937860 --- /dev/null +++ b/screens/policyselectorscreen.py @@ -0,0 +1,91 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +""" +Policy Selector Screen Module + +Provides a Textual Screen wrapper for the PolicySelector widget that manages +the policy selection workflow. +""" + +import logging + +from textual.app import ComposeResult +from textual.screen import Screen + +from widgets.policyselector import PolicySelector + +logger = logging.getLogger(__name__) + + +class PolicySelectorScreen(Screen): + """ + A Textual Screen for policy selection in agent move operations. + + This screen wraps the PolicySelector widget and manages the workflow + of selecting a target policy for bulk agent movements. + + Attributes: + policies: List of available policies (Policy objects or DataFrame). + agent_move_operations: Reference to the parent AgentMoveOperations widget. + """ + + CSS = """ + Screen { + layout: vertical; + background: $surface; + } + """ + + def __init__( + self, + policies, + agent_move_operations=None, + ): + """ + Initialize the PolicySelectorScreen. + + Args: + policies: List of available policies to display. + agent_move_operations: Reference to parent AgentMoveOperations widget. + Used to call back when policy selection is confirmed. + """ + super().__init__() + self.policies = policies + self.agent_move_operations = agent_move_operations + + def compose(self) -> ComposeResult: + """Create the PolicySelector widget.""" + yield PolicySelector(self.policies) + + def on_policy_selector_policy_selected( + self, message: PolicySelector.PolicySelected + ) -> None: + """ + Handle policy selection from the PolicySelector widget. + + When a policy is selected, this handler: + 1. Closes the selector screen + 2. Calls the parent AgentMoveOperations to execute the move + + Args: + message (PolicySelector.PolicySelected): Contains the selected policy. + """ + # Pop this screen to return to AgentMoveOperations + self.app.pop_screen() + + # Call parent widget's method to execute the move + if self.agent_move_operations: + self.agent_move_operations._execute_move_to_policy(message.policy) diff --git a/utils/selector.py b/utils/selector.py index 0a0f498..2cac574 100644 --- a/utils/selector.py +++ b/utils/selector.py @@ -33,7 +33,7 @@ class Selector: def _display_choices( items: List[Any], label_func: Callable[[Any], str], - num_columns: int = 4, + num_columns: int = 3, header: str = "Available Choices:", ) -> None: # Force single column if items are DataFrame rows @@ -54,7 +54,7 @@ class Selector: @staticmethod def _display_selected_items( - selected: List[Any], label_func: Callable[[Any], str], num_columns: int = 4 + selected: List[Any], label_func: Callable[[Any], str], num_columns: int = 3 ) -> None: print(colorText("\nCurrent selections:", "cyan")) if not selected: @@ -93,7 +93,7 @@ class Selector: allow_multiple: bool = False, prompt_each: bool = False, header: str = "Available Choices:", - num_columns: int = 4, + num_columns: int = 3, ) -> Union[Optional[Any], List[Any]]: if not items: logger.warning("No items available for selection.") diff --git a/utils/tui.py b/utils/tui.py index 6eb070d..9fb1c54 100644 --- a/utils/tui.py +++ b/utils/tui.py @@ -23,6 +23,7 @@ from flows.prepPolicy import menu_policy_enforce from flows.quietAgent import findQuietAgents from models.agent import Agent from models.policy import Policy +from screens.moveagentworkflowscreen import MoveAgentWorkflowScreen from screens.otpworkflowscreen import OTPWorkflowScreen from services.agenthandler import findAgents, moveAgents, toggleEnforcement from services.API import AirlockAPIWrapper @@ -30,9 +31,12 @@ from services.policyhandler import confirmUpdateAfromE from utils.configmanager import load_env from utils.setup import get_base_directory, load_user_config from utils.utils import open_directory +from widgets.agentmoveoperations import AgentMoveOperations from widgets.multiagentselector import MultiAgentSelector from widgets.OTP_generate import OTPGenerator from widgets.policytreewidget import PolicyTreeWidget +from widgets.resultsdisplay import ResultsDisplay +from widgets.retro_terminal_theme import get_retro_terminal_theme from widgets.themeselector import ThemeSelector dotenv.load_dotenv() @@ -106,6 +110,7 @@ class MainMenuScreen(Screen): ("πŸ”‡ - Find Quiet Hosts", "find_quiet_button"), ], "move": [ + ("πŸ”„ - Move Agent Workflow", "move_agent_workflow_button"), ("βœ… - Move to local approval", "move_local_button"), ("πŸ”„ - Move to Audit/Enforcement", "move_audit_button"), ("πŸ”€ - Move - Other", "move_other_button"), @@ -261,6 +266,47 @@ class MainMenuScreen(Screen): self.app.exit() + def on_agent_move_operations_operation_complete( + self, message: AgentMoveOperations.OperationComplete + ) -> None: + """Handle completion of agent move operation - show results.""" + logger.info( + "Agent move operation completed: %s, %d successful, %d unsuccessful", + message.operation, + len(message.successful), + len(message.unsuccessful), + ) + + # Format results for display + successful_text = "\n".join( + [f"{agent.hostname}" for agent, _ in message.successful] + ) + unsuccessful_text = "\n".join( + [f"{agent.hostname}: {error}" for agent, error in message.unsuccessful] + ) + + # Remove the operations widget + try: + ops_widget = self.query_one(AgentMoveOperations) + ops_widget.remove() + except Exception: + pass + + # Show results + self.query_one("#content", Vertical).mount( + ResultsDisplay(message.operation, successful_text, unsuccessful_text) + ) + + def on_results_display_go_back(self, message: ResultsDisplay.GoBack) -> None: + """Handle back button from results display.""" + try: + results_widget = self.query_one(ResultsDisplay) + results_widget.remove() + except Exception: + pass + # Return to main menu + self.app.pop_screen() + def on_directory_tree_file_selected( self, event: DirectoryTree.FileSelected ) -> None: @@ -282,6 +328,11 @@ class MainMenuScreen(Screen): _PENDING_JOB = ("legacy", findAgents, (self.app.api, False), {}) case "find_quiet_button": _PENDING_JOB = ("legacy", findQuietAgents, (self.app.api,), {}) + case "move_agent_workflow_button": + # Push Move Agent workflow screen + self.app.push_screen(MoveAgentWorkflowScreen(self.app.devices)) + event.stop() + return # Don't exit the app case "move_local_button": _PENDING_JOB = ( "legacy", @@ -349,12 +400,21 @@ class Loxide(App): self.devices = [ Agent(**row.to_dict()) for _, row in api.agent_find_all().iterrows() ] + + # Enrich agents with policy information + if self.policies and self.devices: + for agent in self.devices: + agent.enrich_with_policies(self.policies) + logger.debug( + f"Enriched {len(self.devices)} agents with policy information" + ) except Exception as exc: logger.error("Failed to load policies/devices: %s", exc) self.policies = None self.devices = None def on_mount(self, api: AirlockAPIWrapper) -> None: + self.register_theme(get_retro_terminal_theme()) self.theme = self._textual_theme self.push_screen(MainMenuScreen(api)) diff --git a/utils/utils.py b/utils/utils.py index 26b2023..b6ed034 100644 --- a/utils/utils.py +++ b/utils/utils.py @@ -151,44 +151,6 @@ def irtang(): ) -def displayIntro(): - - print( - colorText( - r""" - _____ .__ .__ __ ___________ .__ - / _ \ |__|______| | ____ ____ | | __ \__ ___/___ ____ | | ______ - / /_\ \| \_ __ \ | / _ \_/ ___\| |/ / | | / _ \ / _ \| | / ___/ -/ | \ || | \/ |_( <_> ) \___| < | |( <_> | <_> ) |__\___ \ -\____|__ /__||__| |____/\____/ \___ >__|_ \ |____| \____/ \____/|____/____ > - \/ \/ \/ \/ -""", - "cyan", - ) - ) - - -def welcome(): - print( - colorText( - "=================================================================================", - "cyan", - ) - ) - print( - colorText( - "======================== Welcome to the Airlock API Tool ========================", - "cyan", - ) - ) - print( - colorText( - "=================================================================================", - "cyan", - ) - ) - - def section_header(title): print( colorText( diff --git a/widgets/OTP_generate.py b/widgets/OTP_generate.py index 6aff859..a3476c6 100644 --- a/widgets/OTP_generate.py +++ b/widgets/OTP_generate.py @@ -6,7 +6,16 @@ from textual.css.query import NoMatches from textual.message import Message from textual.reactive import reactive from textual.widget import Widget -from textual.widgets import Button, Input, RadioButton, RadioSet, Static, TextArea +from textual.widgets import ( + Button, + Footer, + Header, + Input, + RadioButton, + RadioSet, + Static, + TextArea, +) from models.agent import Agent @@ -70,6 +79,7 @@ class OTPGenerator(Widget): pass def compose(self): + yield Header(show_clock=True, icon="βš™") title_text = Static( f"🎫 Generate One Time Passes for {len(self.devices)} device(s)", id="otpgen_title", @@ -165,6 +175,7 @@ class OTPGenerator(Widget): copy_button.styles.margin = (1, 0, 0, 0) copy_button.styles.display = "none" yield copy_button + yield Footer() def on_mount(self) -> None: """Set initial button state.""" diff --git a/widgets/agentmoveoperations.py b/widgets/agentmoveoperations.py new file mode 100644 index 0000000..1e55fdf --- /dev/null +++ b/widgets/agentmoveoperations.py @@ -0,0 +1,704 @@ +""" +Agent Move Operations Widget Module + +This module provides a Textual-based UI widget for performing bulk operations on +agent devices in the Airlock system. It allows users to: +- View selected agents and their current policy assignments +- Move agents to local approval mode with OTP enforcement +- Toggle agents between audit and enforcement policy modes +- Select and move agents to alternate policies (future implementation) + +The widget tracks operation state, manages button availability, and displays +results with success/failure summaries that can be copied to clipboard. + +Dependencies: + - textual: TUI framework for building the widget and UI components + - models.agent: Agent model class + - services.agenthandler: Core agent operation functions + - flows.localApproval: Local approval workflow handling +""" + +import logging +from typing import List + +from textual.containers import Horizontal, Vertical +from textual.css.query import NoMatches +from textual.message import Message +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Button, DataTable, Header, Static, TextArea + +from models.agent import Agent +from screens.policyselectorscreen import PolicySelectorScreen + +logger = logging.getLogger(__name__) + + +class AgentMoveOperations(Widget): + """ + A Textual widget for managing bulk agent operations and policy migrations. + + This widget provides a comprehensive UI for performing operations on multiple + selected agents. It displays the list of target agents and provides buttons to + trigger various bulk operations like toggling policy modes or enabling local approval. + + The widget manages its own state through reactive properties and provides real-time + feedback on operation progress and results. Operations are executed sequentially + per agent with error handling that tracks both successful and failed operations. + + Attributes: + operation_in_progress (reactive[bool]): Tracks whether an operation is currently + executing. Used to disable buttons during execution. + selected_operation (reactive[str]): Tracks which operation type is currently + selected or in progress (e.g., "local_approval", "toggle_enforcement"). + + Example: + ```python + agents = [agent1, agent2, agent3] + widget = AgentMoveOperations(agents) + ``` + """ + + # Reactive property to track if an operation is in progress + operation_in_progress = reactive(False) + # Tracks the currently selected operation type + selected_operation = reactive("") + + class OperationComplete(Message): + """ + Message posted when a bulk operation completes. + + This message is broadcast to parent widgets/screens to notify them of + operation completion along with detailed results. It contains the list + of agents that were processed and the outcome for each. + + Attributes: + operation (str): Name of the operation that completed (e.g., "Local Approval Mode"). + agents (List[Agent]): List of all agents that were targeted by the operation. + successful (List[tuple]): List of (Agent, result_data) tuples for successfully + processed agents. Result data varies by operation type. + unsuccessful (List[tuple]): List of (Agent, error_message) tuples for agents + where the operation failed. Error message is a string explaining the failure. + """ + + def __init__( + self, + operation: str, + agents: List[Agent], + successful: List[tuple], + unsuccessful: List[tuple], + ): + super().__init__() + self.operation = operation + self.agents = agents + self.successful = successful # List of (agent, result) tuples + self.unsuccessful = unsuccessful # List of (agent, error) tuples + + def __init__(self, agents: List[Agent]): + """ + Initialize the AgentMoveOperations widget. + + Args: + agents (List[Agent]): List of Agent objects to perform operations on. + These agents will be displayed in the widget's agent table. + """ + super().__init__() + self.agents = agents + + def watch_operation_in_progress(self, old_value: bool, new_value: bool) -> None: + """ + React to changes in the operation_in_progress reactive property. + + This is called automatically by Textual when operation_in_progress changes. + It updates the button states to reflect whether an operation is running. + + Args: + old_value (bool): Previous value of operation_in_progress. + new_value (bool): New value of operation_in_progress. + """ + self._update_button_states() + + def _update_button_states(self) -> None: + """ + Update the enabled/disabled state of operation buttons based on current status. + + This method implements the following logic: + - If an operation is in progress: disable all buttons + - If an operation is selected: disable only that operation's button + - If no operation is selected: enable all buttons + + The state transitions prevent users from starting multiple operations + simultaneously and provide visual feedback on which operation is active. + + Handles NoMatches exceptions gracefully in case buttons are not yet rendered. + """ + try: + local_approval_btn = self.query_one("#local_approval_btn", Button) + toggle_enforcement_btn = self.query_one("#toggle_enforcement_btn", Button) + other_policy_btn = self.query_one("#other_policy_btn", Button) + + # If operation in progress, disable all + if self.operation_in_progress: + local_approval_btn.disabled = True + toggle_enforcement_btn.disabled = True + other_policy_btn.disabled = True + else: + # If an operation was selected, keep it disabled, enable others + if self.selected_operation: + local_approval_btn.disabled = ( + self.selected_operation == "local_approval" + ) + toggle_enforcement_btn.disabled = ( + self.selected_operation == "toggle_enforcement" + ) + other_policy_btn.disabled = ( + self.selected_operation == "other_policy" + ) + else: + # Enable all buttons + local_approval_btn.disabled = False + toggle_enforcement_btn.disabled = False + other_policy_btn.disabled = False + + except NoMatches: + pass + + def _display_results( + self, operation_name: str, successful: list, unsuccessful: list + ) -> None: + """ + Display operation results in the results text area. + + Formats the results into a human-readable summary including: + - Operation name and separator + - List of successful operations with agent hostnames + - List of failed operations with agent hostnames and error messages + - Summary statistics (total successful/failed count) + + The results are displayed in the results_text TextArea widget and the + results container is made visible after being initially hidden. + + Args: + operation_name (str): Human-readable name of the operation (e.g., "Local Approval Mode"). + successful (list): List of (Agent, result_data) tuples for successful operations. + unsuccessful (list): List of (Agent, error_message) tuples for failed operations. + """ + try: + # Build results text + results_lines = [ + f"Operation: {operation_name}", + f"{'=' * 50}", + "", + f"Òœ… Successful ({len(successful)}):", + ] + + if successful: + for agent, result in successful: + results_lines.append(f" Ò€’ {agent.hostname}") + else: + results_lines.append(" (none)") + + results_lines.append("") + results_lines.append(f"ҝŒ Failed ({len(unsuccessful)}):") + + if unsuccessful: + for agent, error in unsuccessful: + results_lines.append(f" Ò€’ {agent.hostname}: {error}") + else: + results_lines.append(" (none)") + + results_lines.append("") + results_lines.append(f"{'=' * 50}") + results_lines.append( + f"Total: {len(successful)} successful, {len(unsuccessful)} failed" + ) + + results_text_widget = self.query_one("#results_text", TextArea) + results_text_widget.text = "\n".join(results_lines) + + # Show results container + results_container = self.query_one("#results_container", Vertical) + results_container.styles.display = "block" + + except Exception as e: + logger.error(f"Error displaying results: {e}") + + def compose(self): + """ + Build the UI layout for the AgentMoveOperations widget. + + This method is called by Textual to create the widget's UI structure. + It builds a two-column layout with: + - Left side: Agent table showing selected agents and their current policies + - Right side: Operation buttons and results display area + - Bottom: Navigation buttons (Back, Reset) + + The layout is responsive with: + - Agent table: 2/3 width + - Operations panel: 1/3 width + - Results area: Initially hidden, shown after operation completion + """ + yield Header(show_clock=True, icon="βš™") + title_text = Static( + f"↔️ Move Agent Operations - {len(self.agents)} device(s) selected", + id="move_ops_title", + ) + title_text.styles.margin = (0, 0, 1, 0) + yield title_text + + with Horizontal() as main_layout: + main_layout.styles.height = "auto" + + # Left side - Agent list + with Vertical() as left_side: + left_side.styles.width = "2fr" + left_side.styles.height = "auto" + + agents_label = Static("Selected Agents:") + agents_label.styles.margin = (0, 0, 0, 0) + yield agents_label + + # Create a DataTable to show agents with their current policies + agent_table = DataTable(id="agent_table") + agent_table.styles.height = "1fr" + agent_table.styles.margin = (1, 0, 1, 0) + yield agent_table + + # Right side - Operation buttons + with Vertical() as right_side: + right_side.styles.width = "1fr" + right_side.styles.height = "auto" + + operations_label = Static("Operations:") + operations_label.styles.margin = (0, 0, 1, 0) + yield operations_label + + # Operation buttons + local_approval_btn = Button( + "βœ… Local Approval Mode", id="local_approval_btn" + ) + local_approval_btn.styles.width = "100%" + local_approval_btn.styles.margin = (0, 0, 1, 0) + yield local_approval_btn + + toggle_enforcement_btn = Button( + "πŸ”„ Toggle Audit/Enforcement", id="toggle_enforcement_btn" + ) + toggle_enforcement_btn.styles.width = "100%" + toggle_enforcement_btn.styles.margin = (0, 0, 1, 0) + yield toggle_enforcement_btn + + other_policy_btn = Button( + "πŸ”€ Move to Other Policy", id="other_policy_btn" + ) + other_policy_btn.styles.width = "100%" + other_policy_btn.styles.margin = (0, 0, 1, 0) + yield other_policy_btn + + # Status label + status_label = Static("", id="status_label") + status_label.styles.margin = (2, 0, 0, 0) + yield status_label + + # Results display area (initially hidden) + with Vertical(id="results_container") as results_container: + results_container.styles.height = "auto" + results_container.styles.margin = (1, 0, 0, 0) + results_container.styles.display = "none" + + results_label = Static("Γ°ΕΈβ€œΕ  Results:", id="results_label") + results_label.styles.margin = (0, 0, 0, 0) + yield results_label + + results_text = TextArea(id="results_text", read_only=True) + results_text.styles.height = 15 + results_text.styles.margin = (0, 0, 1, 0) + yield results_text + + copy_results_btn = Button( + "Γ°ΕΈβ€œβ€Ή Copy Results to Clipboard", id="copy_results_btn" + ) + copy_results_btn.styles.width = "100%" + yield copy_results_btn + + # Bottom buttons + with Horizontal() as button_row: + button_row.styles.height = "auto" + button_row.styles.margin = (1, 0, 0, 0) + + back_button = Button("Ò† Back", id="back_button") + back_button.styles.width = "1fr" + yield back_button + + reset_button = Button("Γ°ΕΈβ€β€ž Reset Selection", id="reset_button") + reset_button.styles.width = "1fr" + yield reset_button + + def on_mount(self) -> None: + """ + Initialize widget after it has been mounted on the screen. + + This Textual lifecycle method is called after the widget is added to the DOM. + It performs initialization tasks: + - Populates the agent table with columns for Hostname, Policy, and Status + - Adds rows to the table for each agent in self.agents + - Initializes button states based on current widget state + + The agent table displays agent.hostname, agent.groupname (or "Unknown"), + and agent.status_text (or "Unknown") for each agent. + """ + table = self.query_one("#agent_table", DataTable) + table.add_columns("Hostname", "Current Policy", "Status") + + for agent in self.agents: + table.add_row( + agent.hostname, + agent.groupname or "Unknown", + agent.status_text or "Unknown", + ) + + self._update_button_states() + + def on_button_pressed(self, event: Button.Pressed): + """ + Handle button press events from the widget. + + This Textual event handler routes button presses to appropriate actions: + - back_button: Pop this screen (return to parent) + - reset_button: Clear operation state and hide results + - copy_results_btn: Copy results text to clipboard (requires pyperclip) + - local_approval_btn: Start local approval operation + - toggle_enforcement_btn: Start toggle audit/enforcement operation + - other_policy_btn: Start move to other policy operation + + After handling, event.stop() is called to prevent event propagation. + + Args: + event (Button.Pressed): The button press event containing the button reference. + """ + + btn_id = event.button.id + + if btn_id == "back_button": + self.app.pop_screen() + event.stop() + + elif btn_id == "reset_button": + # Reset operation selection + self.selected_operation = "" + self.operation_in_progress = False + status_label = self.query_one("#status_label", Static) + status_label.update("") + # Hide results + try: + results_container = self.query_one("#results_container", Vertical) + results_container.styles.display = "none" + except NoMatches: + pass + event.stop() + + elif btn_id == "copy_results_btn": + try: + results_text = self.query_one("#results_text", TextArea) + import pyperclip + + pyperclip.copy(results_text.text) + self.app.notify( + "Òœ… Results copied to clipboard!", + severity="information", + timeout=2, + ) + except ImportError: + self.app.notify( + "Òő ï¸ pyperclip not installed. Run: pip install pyperclip", + severity="warning", + ) + except Exception as e: + self.app.notify(f"ҝŒ Failed to copy: {str(e)}", severity="error") + event.stop() + + elif btn_id == "local_approval_btn": + self._start_local_approval_operation() + event.stop() + + elif btn_id == "toggle_enforcement_btn": + self._start_toggle_enforcement_operation() + event.stop() + + elif btn_id == "other_policy_btn": + self._start_other_policy_operation() + event.stop() + + def _start_local_approval_operation(self) -> None: + """ + Execute the local approval mode operation on all selected agents. + + This operation performs the following steps for each agent: + 1. Generate a unique batch ID (current Unix timestamp) + 2. Create a local approval OTP with default duration of 360 minutes (6 hours) + 3. Move the agent to its related audit policy mode + + The operation: + - Sets operation state flags (selected_operation, operation_in_progress) + - Updates the status label with progress indicator + - Iterates through all agents, tracking successful and unsuccessful operations + - Displays formatted results via _display_results() + - Posts an OperationComplete message for parent widget handling + + Agents that fail are logged and added to the unsuccessful list with error details. + The operation completes and returns to a non-busy state regardless of individual + agent success/failure. + + Note: The OTP duration (360 minutes) is currently hardcoded and could be + made configurable in future versions. + """ + self.selected_operation = "local_approval" + self.operation_in_progress = True + + status_label = self.query_one("#status_label", Static) + status_label.update("ҏ³ Moving agents to local approval...") + + # Get API from app + api = self.app.api + + successful = [] + unsuccessful = [] + + try: + import time + + from services.agenthandler import moveAgentToRelatedPolicy + + # Generate batch ID + batch = int(time.time()) + duration = 360 # Default 6 hours, could make this configurable + + for agent in self.agents: + try: + # Add local approval OTP + addLocalApproval(api, batch, duration, agent.agentid) + # Move to audit mode + result = moveAgentToRelatedPolicy(api, agent, "audit") + successful.append((agent, result)) + logger.info( + f"Successfully moved {agent.hostname} to local approval" + ) + except Exception as e: + unsuccessful.append((agent, str(e))) + logger.error( + f"Failed to move {agent.hostname} to local approval: {e}" + ) + + except Exception as e: + logger.error(f"Error during local approval operation: {e}") + status_label.update(f"ҝŒ Error: {str(e)}") + self.operation_in_progress = False + return + + self.operation_in_progress = False + status_label.update("Òœ… Operation complete!") + + # Display results in the widget + self._display_results("Local Approval Mode", successful, unsuccessful) + + # Also post message for potential parent handling + self.post_message( + self.OperationComplete( + "Local Approval Mode", self.agents, successful, unsuccessful + ) + ) + + def _start_toggle_enforcement_operation(self) -> None: + """ + Toggle agents between enforcement and audit policy modes. + + This operation intelligently switches each agent between enforcement and + audit modes based on its current state: + - If agent.groupid is in POLICY_MAP_ENF_AUD: currently enforcing Ò†' move to audit + - Otherwise: currently in audit Ò†' move to enforcement + + The operation: + - Retrieves the enforcement/audit policy relationship map from protected config + - Sets operation state flags and updates status label + - Iterates through agents, determining current mode and toggling to opposite + - Tracks successful toggles with the new mode in the result message + - Logs both successes and failures + - Displays results and posts OperationComplete message + + The policy relationship map (POLICY_MAP_ENF_AUD) must be present in protected + configuration and maps enforcement policy IDs to audit policy IDs. If the map + is empty or not found, all agents are assumed to be in audit mode and will + be moved to enforcement. + + Returns to a non-busy state after completion regardless of individual results. + """ + self.selected_operation = "toggle_enforcement" + self.operation_in_progress = True + + status_label = self.query_one("#status_label", Static) + status_label.update("ҏ³ Toggling enforcement mode...") + + # Get API from app + api = self.app.api + + successful = [] + unsuccessful = [] + + try: + from services.agenthandler import moveAgentToRelatedPolicy + from utils.configmanager import get_protected_json + + policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") + + for agent in self.agents: + try: + # Determine current mode and toggle + if agent.groupid in policy_relationship_map: + # Currently in enforcement, move to audit + result = moveAgentToRelatedPolicy(api, agent, "audit") + mode = "audit" + else: + # Currently in audit, move to enforcement + result = moveAgentToRelatedPolicy(api, agent, "enforcement") + mode = "enforcement" + + successful.append((agent, f"Moved to {mode}: {result}")) + logger.info(f"Successfully toggled {agent.hostname} to {mode}") + except Exception as e: + unsuccessful.append((agent, str(e))) + logger.error(f"Failed to toggle {agent.hostname}: {e}") + + except Exception as e: + logger.error(f"Error during toggle enforcement operation: {e}") + status_label.update(f"ҝŒ Error: {str(e)}") + self.operation_in_progress = False + return + + self.operation_in_progress = False + status_label.update("Òœ… Operation complete!") + + # Display results in the widget + self._display_results("Toggle Audit/Enforcement", successful, unsuccessful) + + # Also post message for potential parent handling + self.post_message( + self.OperationComplete( + "Toggle Audit/Enforcement", self.agents, successful, unsuccessful + ) + ) + + def _start_other_policy_operation(self) -> None: + """ + Move agents to a user-selected policy (currently unimplemented). + + This operation is intended to allow bulk movement of selected agents to any + alternative policy via a policy selection dialog. Currently, this feature + is not fully implemented. + + Planned Implementation: + 1. Push a new policy selector screen (TUI modal/overlay) + 2. Allow user to choose target policy from available options + 3. Move all selected agents to the chosen policy + 4. Display results like other operations + + Current Behavior: + - Sets selected_operation to "other_policy" + - Displays "Policy selection not yet implemented" status message + - Clears selected_operation without performing any action + + TODO: Complete implementation by: + - Creating a policy selector screen component + - Implementing the policy selection logic + - Integrating with moveAgentToPolicy API call + - Adding proper result tracking and display + """ + self.selected_operation = "other_policy" + self.operation_in_progress = True + + status_label = self.query_one("#status_label", Static) + status_label.update("Loading available policies...") + + try: + # Fetch all policies from API + api = self.app.api + + # Fetch all available policies + all_policies_df = api.policy_find_all() + + if all_policies_df.empty: + status_label.update("No policies available") + self.operation_in_progress = False + self.selected_operation = "" + return + + # Create and push the policy selector screen + policy_selector_screen = PolicySelectorScreen( + policies=all_policies_df, + agent_move_operations=self, + ) + self.app.push_screen(policy_selector_screen) + + except Exception as e: + logger.error(f"Error loading policies: {e}") + status_label.update(f"Error: {str(e)}") + self.operation_in_progress = False + self.selected_operation = "" + self.app.notify(f"Failed to load policies: {str(e)}", severity="error") + + def _execute_move_to_policy(self, target_policy) -> None: + """ + Execute the actual move of agents to the selected policy. + + Moves each agent sequentially to the target policy, tracking success/failure. + Updates the status label and displays results upon completion. + + Args: + target_policy: The Policy object selected by the user. + """ + status_label = self.query_one("#status_label", Static) + status_label.update(f"Moving agents to {target_policy.name}...") + + api = self.app.api + successful = [] + unsuccessful = [] + + try: + for agent in self.agents: + try: + # Move agent to target policy + result = api.agent_move(agent.agentid, target_policy.groupid) + successful.append((agent, f"Moved to {target_policy.name}")) + logger.info( + f"Successfully moved {agent.hostname} to policy {target_policy.name}" + ) + except Exception as e: + unsuccessful.append((agent, str(e))) + logger.error( + f"Failed to move {agent.hostname} to policy {target_policy.name}: {e}" + ) + + except Exception as e: + logger.error(f"Error during move to policy operation: {e}") + status_label.update(f"Error: {str(e)}") + self.operation_in_progress = False + return + + self.operation_in_progress = False + status_label.update("Operation complete!") + + # Display results in the widget + self._display_results( + f"Move to {target_policy.name}", + successful, + unsuccessful, + ) + + # Also post message for potential parent handling + self.post_message( + self.OperationComplete( + f"Move to {target_policy.name}", + self.agents, + successful, + unsuccessful, + ) + ) diff --git a/widgets/multiagentselector.py b/widgets/multiagentselector.py index e46c636..dd0fe1b 100644 --- a/widgets/multiagentselector.py +++ b/widgets/multiagentselector.py @@ -6,7 +6,15 @@ from textual.containers import Horizontal, Vertical from textual.css.query import NoMatches from textual.message import Message from textual.widget import Widget -from textual.widgets import Button, SelectionList, Static, Switch, TextArea +from textual.widgets import ( + Button, + Footer, + Header, + SelectionList, + Static, + Switch, + TextArea, +) from models.agent import Agent @@ -31,6 +39,7 @@ class MultiAgentSelector(Widget): self._match_type = value def compose(self): + yield Header(show_clock=True, icon="βš™") title_text = Static("πŸ–§ Multi-Agent Selector", id="selector_title") title_text.styles.margin = (0, 0, 0, 1) yield title_text @@ -98,6 +107,7 @@ class MultiAgentSelector(Widget): right_pane.styles.width = "2fr" yield SelectionList(id="match_results") yield Static(id="unmatched_label") + yield Footer() def on_switch_changed(self, event: Switch.Changed): self.match_type = "fuzzy" if event.value else "exact" diff --git a/widgets/policyselector.py b/widgets/policyselector.py new file mode 100644 index 0000000..1796968 --- /dev/null +++ b/widgets/policyselector.py @@ -0,0 +1,505 @@ +""" +Policy Selector Widget Module + +Provides a Textual widget for selecting target policies for bulk agent operations. +Allows users to browse available policies and select one as the destination for +moving agents. Automatically excludes parent/logical policies. +""" + +import logging +import re +from typing import Optional + +import pandas as pd +from textual.containers import Horizontal, Vertical +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Button, DataTable, Footer, Header, Static, TextArea + +from models.policy import Policy + +logger = logging.getLogger(__name__) + + +class PolicySelector(Widget): + """ + A Textual widget for selecting a target policy for agent operations. + + This widget displays available policies in a table and allows users to select + one policy as the destination for bulk agent movements. It automatically excludes: + - Parent/logical policies (where parent == "global-policy-settings") + - Specified policy IDs (e.g., the current policy) + + Features: + - Wildcard filtering (* and ?) + - Interactive table for policy browsing + - Explicit confirm button for selection + - Cancel/back button to dismiss + + Attributes: + policies (list[Policy]): List of available Policy objects to display. + excluded_policy_ids (set[str]): Set of policy IDs to exclude from selection. + selected_policy (Optional[Policy]): The currently selected policy (if any). + + Automatically Filtered Out: + - Policies with parent == "global-policy-settings" (parent policies for organization) + - Any policies in excluded_policy_ids set + + Example: + ```python + policies = [policy1, policy2, policy3] + widget = PolicySelector(policies, excluded_policy_ids={current_policy.groupid}) + ``` + """ + + class PolicySelected(Message): + """ + Message posted when a policy is selected. + + Attributes: + policy (Policy): The selected policy object. + """ + + def __init__(self, policy: Policy): + super().__init__() + self.policy = policy + + def __init__(self, policies: list): + """ + Initialize the PolicySelector widget. + + Args: + policies (list): List of Policy objects or DataFrame rows to display. + Can be a list of Policy objects or a pandas DataFrame of policy data. + """ + super().__init__() + self.policies = policies + self.selected_policy: Optional[Policy] = None + self._filtered_policies = [] + self._displayed_policies = [] # Track what's currently shown in the table + self._filter_text = "" + + def compose(self): + """ + Build the UI layout for the PolicySelector widget. + + The layout includes: + - Title indicating policy selection + - Search/filter text area with wildcard support + - Filter help text showing wildcard options + - Apply Filter button + - Clear Filter button + - Confirm Selection button + - Policy table displaying available policies + - Back and Continue buttons for navigation + """ + yield Header(show_clock=True, icon="βš™") + title_text = Static( + "🎯 Select Target Policy", + id="policy_selector_title", + ) + title_text.styles.margin = (0, 0, 1, 0) + yield title_text + + with Horizontal() as main_layout: + main_layout.styles.height = "auto" + + # Left side - Filter and controls + with Vertical() as left_side: + left_side.styles.width = "1fr" + left_side.styles.height = "auto" + + filter_label = Static("Filter Policies:") + filter_label.styles.margin = (0, 0, 0, 0) + yield filter_label + + filter_input = TextArea( + id="policy_filter", + text="", + ) + filter_input.styles.height = 3 + filter_input.styles.margin = (0, 0, 1, 0) + yield filter_input + + filter_help = Static("(Use * and ? for wildcards)", id="filter_help") + filter_help.styles.margin = (0, 0, 1, 0) + yield filter_help + + apply_button = Button("βœ“ Apply Filter", id="filter_button") + apply_button.styles.width = "100%" + apply_button.styles.margin = (0, 0, 1, 0) + yield apply_button + + clear_button = Button("πŸ—‘οΈ Clear Filter", id="clear_filter_button") + clear_button.styles.width = "100%" + clear_button.styles.margin = (0, 0, 1, 0) + yield clear_button + + confirm_button = Button("βœ… Confirm Selection", id="confirm_button") + confirm_button.styles.width = "100%" + confirm_button.styles.margin = (1, 0, 1, 0) + yield confirm_button + + selected_label = Static("", id="selected_policy_label") + selected_label.styles.margin = (2, 0, 1, 0) + yield selected_label + + # Right side - Policy table + with Vertical() as right_side: + right_side.styles.width = "2fr" + right_side.styles.height = "auto" + + table_label = Static("Available Policies:") + table_label.styles.margin = (0, 0, 0, 0) + yield table_label + + policy_table = DataTable(id="policy_table", cursor_type="row") + policy_table.styles.height = "1fr" + policy_table.styles.margin = (1, 0, 1, 0) + yield policy_table + + # Bottom buttons + with Horizontal() as button_row: + button_row.styles.height = "auto" + button_row.styles.margin = (1, 0, 0, 0) + + cancel_button = Button("βœ• Cancel", id="back_button", variant="error") + cancel_button.styles.width = "1fr" + yield cancel_button + + continue_button = Button( + "β–Ά Continue", + id="continue_button", + variant="primary", + ) + continue_button.styles.width = "1fr" + continue_button.styles.margin = (0, 0, 0, 1) + yield continue_button + yield Footer() + + def on_mount(self) -> None: + """ + Initialize the policy table when the widget is mounted. + + Populates the table with column (Policy Name) and rows for each + available policy (excluding those in excluded_policy_ids and parent policies). + Sets up event handlers for table row selection. + + Filters out: + - Parent policies (where parent == "global-policy-settings") + """ + table = self.query_one("#policy_table", DataTable) + + # Configure table for row selection + table.cursor_type = "row" + table.zebra_stripes = True + + # Only add Policy Name column + table.add_columns("Policy Name") + + # Filter out excluded policies and convert to list if DataFrame + if isinstance(self.policies, pd.DataFrame): + policies_list = self.policies.to_dict("records") + else: + policies_list = self.policies + + self._filtered_policies = [] + self._displayed_policies = [] # Initialize displayed list + + for policy_data in policies_list: + # Handle both Policy objects and dict/DataFrame rows + if isinstance(policy_data, Policy): + policy_id = policy_data.groupid + policy_name = policy_data.name + parent = policy_data.parent + else: + policy_id = policy_data.get("groupid", "Unknown") + policy_name = policy_data.get("name", "Unknown") + parent = policy_data.get("parent", None) + + # Skip parent policies (logical policies that shouldn't have devices) + if parent == "global-policy-settings": + logger.debug(f"Skipping parent policy: {policy_name}") + continue + + self._filtered_policies.append(policy_data) + self._displayed_policies.append(policy_data) # Add to displayed list + + table.add_row( + policy_name, + key=policy_id, + ) + + def on_button_pressed(self, event: Button.Pressed): + """ + Handle button press events from the widget. + + Routes to: + - back_button (Cancel): Pop screen without selecting + - filter_button (Apply Filter): Filter policies with wildcard support + - clear_filter_button: Clear filter and show all policies + - confirm_button: Confirm selection and post message + - continue_button: Continue without posting message + + Args: + event (Button.Pressed): The button press event. + """ + btn_id = event.button.id + + if btn_id == "back_button": + self.app.pop_screen() + event.stop() + + elif btn_id == "filter_button": + self._apply_filter() + event.stop() + + elif btn_id == "clear_filter_button": + self._clear_filter() + event.stop() + + elif btn_id == "confirm_button": + self._confirm_selection() + event.stop() + + elif btn_id == "continue_button": + self.app.pop_screen() + event.stop() + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """ + Handle row selection in the policy table. + + Updates the selected_policy and displays the selection in the UI. + + Args: + event: DataTable.RowSelected event containing the selected row data. + """ + try: + # Get the row key from the event + row_key = event.row_key + if row_key is None: + return + + # Find the policy with matching groupid + for policy_data in self._displayed_policies: + if isinstance(policy_data, Policy): + if policy_data.groupid == row_key.value: + self.selected_policy = policy_data + break + else: + if policy_data.get("groupid") == row_key.value: + self.selected_policy = Policy( + groupid=policy_data.get("groupid"), + hidden=policy_data.get("hidden", False), + name=policy_data.get("name"), + parent=policy_data.get("parent"), + ) + break + + if self.selected_policy: + # Update selection display + label = self.query_one("#selected_policy_label", Static) + label.update(f"βœ“ Selected: {self.selected_policy.name}") + + # Log for debugging + logger.debug( + f"Selected policy: {self.selected_policy.name} (ID: {self.selected_policy.groupid})" + ) + self.app.notify( + f"Selected: {self.selected_policy.name}", + severity="information", + timeout=1, + ) + + except Exception as e: + logger.error(f"Error handling row selection: {e}") + self.app.notify(f"Selection error: {str(e)}", severity="error") + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + """ + Handle row highlighting (cursor movement) in the table. + + This provides immediate visual feedback when navigating rows. + """ + try: + # Get the row key from the event + row_key = event.row_key + if row_key is None: + return + + # Find the highlighted policy + highlighted_name = None + for policy_data in self._displayed_policies: + if isinstance(policy_data, Policy): + if policy_data.groupid == row_key.value: + highlighted_name = policy_data.name + break + else: + if policy_data.get("groupid") == row_key.value: + highlighted_name = policy_data.get("name") + break + + if highlighted_name: + label = self.query_one("#selected_policy_label", Static) + label.update(f"β†’ Highlighting: {highlighted_name}") + + except Exception as e: + logger.error(f"Error handling row highlight: {e}") + + def _apply_filter(self) -> None: + """ + Apply filter text to policy list with wildcard support. + + Supports wildcards: + - * matches any sequence of characters + - ? matches a single character + + Examples: + - "policy*" matches "policy_prod", "policy_dev", etc. + - "policy?" matches "policy1", "policy2", etc. + - "*audit*" matches anything containing "audit" + - "*test*" matches "AT Testing", "test_policy", etc. + + Filters policies by name or ID (case-insensitive) and refreshes the table display + with only matching policies. Only filters from already-filtered list + (which excludes parent policies and excluded IDs). + """ + try: + filter_input = self.query_one("#policy_filter", TextArea) + filter_text = filter_input.text.strip() + + table = self.query_one("#policy_table", DataTable) + table.clear() + + # Clear the displayed policies list + self._displayed_policies = [] + + # Compile wildcard pattern if filter text is provided + pattern = None + if filter_text: + # Escape special regex chars but preserve wildcards + pattern_text = re.escape(filter_text.lower()) + pattern_text = pattern_text.replace(r"\*", ".*").replace(r"\?", ".") + # Use search() for partial matching + pattern = re.compile(pattern_text, re.IGNORECASE) + + # Filter policies based on search text + for policy_data in self._filtered_policies: + # Handle both Policy objects and dict/DataFrame rows + if isinstance(policy_data, Policy): + policy_name = policy_data.name.lower() + policy_id = policy_data.groupid.lower() + display_name = policy_data.name + key_id = policy_data.groupid + else: + policy_name = str(policy_data.get("name", "")).lower() + policy_id = str(policy_data.get("groupid", "Unknown")).lower() + display_name = policy_data.get("name") + key_id = policy_data.get("groupid") + + # Match against filter text with wildcard support + if pattern: + # Use search() for partial matching + matches = pattern.search(policy_name) or pattern.search(policy_id) + else: + matches = True + + if matches: + # Add to displayed policies list + self._displayed_policies.append(policy_data) + + # Add row to table + table.add_row( + display_name, + key=key_id, + ) + + displayed_count = len(self._displayed_policies) + status_text = f"πŸ“Š Showing {displayed_count} of {len(self._filtered_policies)} policies" + self.app.notify(status_text, severity="information", timeout=2) + + # Clear selection when filter is applied + self.selected_policy = None + label = self.query_one("#selected_policy_label", Static) + label.update("") + + except Exception as e: + logger.error(f"Error applying filter: {e}") + self.app.notify(f"❌ Filter error: {str(e)}", severity="error") + + def _clear_filter(self) -> None: + """ + Clear the filter and display all available policies. + + Resets the filter text and refreshes the table to show all policies + (already excluding parent policies and excluded IDs). + """ + try: + filter_input = self.query_one("#policy_filter", TextArea) + filter_input.text = "" + + table = self.query_one("#policy_table", DataTable) + table.clear() + + # Reset displayed policies to all filtered policies + self._displayed_policies = list(self._filtered_policies) + + # Reload all policies + for policy_data in self._filtered_policies: + if isinstance(policy_data, Policy): + policy_id = policy_data.groupid + policy_name = policy_data.name + else: + policy_id = policy_data.get("groupid", "Unknown") + policy_name = policy_data.get("name", "Unknown") + + # Add row with only policy name + table.add_row( + policy_name, + key=policy_id, + ) + + self.selected_policy = None + label = self.query_one("#selected_policy_label", Static) + label.update("") + + except Exception as e: + logger.error(f"Error clearing filter: {e}") + + def on_text_area_changed(self, event) -> None: + """ + Handle TextArea change events - specifically for Enter key in filter. + + When the user types in the filter TextArea and the text ends with a newline, + treat it as pressing Enter and apply the filter. + """ + if event.text_area.id == "policy_filter": + # Check if the text ends with a newline (Enter was pressed) + if event.text_area.text.endswith("\n"): + # Remove the newline that was added + event.text_area.text = event.text_area.text.rstrip("\n") + # Apply the filter + self._apply_filter() + + def _confirm_selection(self) -> None: + """ + Confirm the selected policy and post selection message. + + Posts a PolicySelected message to the parent widget/screen with the + selected policy. If no policy is selected, displays an error notification. + """ + if self.selected_policy is None: + self.app.notify( + "⚠️ Please select a policy first by clicking on a row in the table", + severity="warning", + timeout=3, + ) + return + + # Log confirmation for debugging + logger.info(f"Confirming selection of policy: {self.selected_policy.name}") + self.app.notify( + f"βœ… Confirmed: {self.selected_policy.name}", severity="success", timeout=2 + ) + self.post_message(self.PolicySelected(self.selected_policy)) diff --git a/widgets/resultsdisplay.py b/widgets/resultsdisplay.py new file mode 100644 index 0000000..ebabce7 --- /dev/null +++ b/widgets/resultsdisplay.py @@ -0,0 +1,178 @@ +import logging + +from textual.containers import Horizontal, Vertical +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Button, Footer, Header, Static + +logger = logging.getLogger(__name__) + + +class ResultsDisplay(Widget): + """Widget for displaying operation results in a two-column layout.""" + + CSS = """ + ResultsDisplay { + height: 100%; + } + + #results_screen { + height: 100%; + } + + #results_title { + text-align: center; + margin: 1 0; + text-style: bold; + } + + #results_layout { + height: 1fr; + margin: 1 0; + } + + #left_column, #right_column { + width: 1fr; + height: 100%; + border: solid green; + padding: 1; + } + + #right_column { + border: solid red; + } + + #success_label, #failure_label { + text-style: bold; + margin-bottom: 1; + } + + #success_results, #failure_results { + height: 1fr; + overflow-y: auto; + background: $surface; + border: round $primary; + padding: 1; + } + + .copy_button { + margin-top: 1; + width: 100%; + } + + #button_row { + height: auto; + margin: 1 0 0 0; + } + + #back_button { + width: 1fr; + } + """ + + class CopySuccess(Message): + """Posted when success results are copied.""" + + pass + + class CopyFailure(Message): + """Posted when failure results are copied.""" + + pass + + class GoBack(Message): + """Posted when back button is pressed.""" + + pass + + def __init__( + self, operation: str, successful_results: str, unsuccessful_results: str + ) -> None: + super().__init__() + self.operation = operation + self.successful_results = successful_results + self.unsuccessful_results = unsuccessful_results + + def compose(self): + with Vertical(id="results_screen"): + yield Header(show_clock=True, icon="βš™") + # Title + title = Static(f"πŸ“Š {self.operation} - Results", id="results_title") + yield title + + # Two-column layout + with Horizontal(id="results_layout"): + # Left Column - Success + with Vertical(id="left_column"): + yield Static("βœ… Successful", id="success_label") + yield Static(self.successful_results, id="success_results") + yield Button( + "πŸ“‹βœ… Copy Success List", + id="copy_success", + classes="copy_button", + ) + + # Right Column - Failure + with Vertical(id="right_column"): + yield Static("❌ Failed", id="failure_label") + yield Static(self.unsuccessful_results, id="failure_results") + yield Button( + "πŸ“‹βŒ Copy Failure List", + id="copy_failure", + classes="copy_button", + ) + + # Back Button + with Horizontal(id="button_row"): + back_button = Button("← Back", id="back_button") + yield back_button + yield Footer() + + def on_button_pressed(self, event: Button.Pressed) -> None: + btn_id = event.button.id + + if btn_id == "copy_success": + success_widget = self.query_one("#success_results", Static) + try: + import pyperclip + + pyperclip.copy(str(success_widget.renderable)) + self.app.notify( + "Òœ… Success list copied to clipboard!", + severity="information", + timeout=2, + ) + self.post_message(self.CopySuccess()) + except ImportError: + self.app.notify( + "Òő ï¸ pyperclip not installed. Run: pip install pyperclip", + severity="warning", + ) + except Exception as e: + self.app.notify(f"ҝŒ Failed to copy: {str(e)}", severity="error") + event.stop() + + elif btn_id == "copy_failure": + failure_widget = self.query_one("#failure_results", Static) + try: + import pyperclip + + pyperclip.copy(str(failure_widget.renderable)) + self.app.notify( + "Òœ… Failure list copied to clipboard!", + severity="information", + timeout=2, + ) + self.post_message(self.CopyFailure()) + except ImportError: + self.app.notify( + "Òő ï¸ pyperclip not installed. Run: pip install pyperclip", + severity="warning", + ) + except Exception as e: + self.app.notify(f"ҝŒ Failed to copy: {str(e)}", severity="error") + event.stop() + + elif btn_id == "back_button": + self.app.pop_screen() + event.stop() diff --git a/widgets/retro_terminal_theme.py b/widgets/retro_terminal_theme.py new file mode 100644 index 0000000..3ff0d02 --- /dev/null +++ b/widgets/retro_terminal_theme.py @@ -0,0 +1,38 @@ +from textual.color import Color + + +def get_retro_terminal_theme(): + from textual.theme import Theme + + return Theme( + name="retro-terminal", + background=Color.parse("#000000"), + primary=Color.parse("#00ff00"), + secondary=Color.parse("#00aa00"), + success=Color.parse("#00ff00"), + warning=Color.parse("#ffff00"), + error=Color.parse("#ff0000"), + surface=Color.parse("#111111"), + ) + + +RETRO_TERMINAL_CSS = """ +/* Retro terminal CRT effect */ +Screen { + align: center middle; + background: $background; + color: $text; +} + +/* Blocky, pixelated widgets */ +.widget { + border: tall $primary; + background: $surface; + width: 80%; +} + +/* Monospaced font */ +* { + font-family: "Courier New", monospace; +} +""" diff --git a/widgets/themeselector.py b/widgets/themeselector.py index fda7cd1..6a423c2 100644 --- a/widgets/themeselector.py +++ b/widgets/themeselector.py @@ -15,26 +15,25 @@ class ThemeSelector(Widget): self.theme_name = theme_name AVAILABLE_THEMES = [ - ("textual-dark", "textual-dark"), - ("textual-light", "textual-light"), - ("nord", "nord"), - ("gruvbox", "gruvbox"), - ("catppuccin-mocha", "catppuccin-mocha"), - ("dracula", "dracula"), - ("tokyo-night", "tokyo-night"), - ("monokai", "monokai"), - ("flexoki", "flexoki"), - ("catppuccin-latte", "catppuccin-latte"), - ("solarized-light", "solarized-light"), + ("Textual Dark", "textual-dark"), + ("Textual Light", "textual-light"), + ("Nord", "nord"), + ("Gruvbox", "gruvbox"), + ("Catppuccin Mocha", "catppuccin-mocha"), + ("Dracula", "dracula"), + ("Tokyo Night", "tokyo-night"), + ("Monokai", "monokai"), + ("Flexoki", "flexoki"), + ("Catppuccin Latte", "catppuccin-latte"), + ("Solarized Light", "solarized-light"), + ("Retro Terminal", "retro-terminal"), # your custom theme ] def compose(self): yield Static("Theme Options", id="theme_title") - with Vertical() as column: column.styles.width = "1fr" column.styles.height = "auto" - for label, btn_id in self.AVAILABLE_THEMES: yield Button(label, id=f"set_theme_{btn_id}", compact=True) From d9cbce617561f5538caa6fe5de6e399b04bb11b0 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Mon, 10 Nov 2025 15:17:07 -0500 Subject: [PATCH 07/29] Continuing work removing legacy functions and implementing UI changes --- airlock_libs/airlock_libs.pyi | 2 +- screens/otpworkflowscreen.py | 33 ++---- utils/tui.py | 51 +++------ widgets/OTP_generate.py | 5 +- widgets/agentmoveoperations.py | 182 ++++++++++++++++++++------------ widgets/amber_terminal_theme.py | 35 ++++++ widgets/multiagentselector.py | 2 +- widgets/policyselector.py | 32 ++---- widgets/retro_terminal_theme.py | 2 +- widgets/themeselector.py | 3 +- 10 files changed, 188 insertions(+), 159 deletions(-) create mode 100644 widgets/amber_terminal_theme.py diff --git a/airlock_libs/airlock_libs.pyi b/airlock_libs/airlock_libs.pyi index 84bee85..137dc92 100644 --- a/airlock_libs/airlock_libs.pyi +++ b/airlock_libs/airlock_libs.pyi @@ -30,7 +30,7 @@ def history_logging( checkpoint_number: str, policy_names: str, ) -> List[Dict[str, Any]]: - """ + """ Query execution history logs from the Airlock API. Parameters diff --git a/screens/otpworkflowscreen.py b/screens/otpworkflowscreen.py index fa5afc1..f3710b3 100644 --- a/screens/otpworkflowscreen.py +++ b/screens/otpworkflowscreen.py @@ -1,41 +1,24 @@ +# otp_workflow_screen.py + from typing import List from textual.app import ComposeResult from textual.screen import Screen from models.agent import Agent -from widgets.multiagentselector import MultiAgentSelector from widgets.OTP_generate import OTPGenerator class OTPWorkflowScreen(Screen): - """Screen that handles the OTP generation workflow.""" + """Screen that handles the OTP generation workflow without agent selection.""" - def __init__(self, all_agents: List[Agent]): + def __init__(self, selected_agents: List[Agent]): super().__init__() - self.all_agents = all_agents - self.selected_devices = None + self.selected_agents = selected_agents def compose(self) -> ComposeResult: - """Start with the multi-agent selector.""" - yield MultiAgentSelector(self.all_agents) - - def on_multi_agent_selector_agents_selected( - self, message: MultiAgentSelector.AgentsSelected - ) -> None: - """Handle selected agents - switch to OTP generator.""" - self.selected_devices = message.selected_agents - - # Remove the MultiAgentSelector - selector = self.query_one(MultiAgentSelector) - selector.remove() - - # Mount the OTPGenerator with the selected Agent objects - # No need to pass API - it will access self.app.api directly - self.mount(OTPGenerator(self.selected_devices)) + """Directly show the OTP generator for the selected agents.""" + yield OTPGenerator(self.selected_agents) def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None: - """Handle OTP generation request - call the actual OTP generation function.""" - # This will be handled by the main app, but we can also do it here - # For now, just pass it up to the app level - pass + """Handle OTP generation request - pass it up to the app level if needed.""" diff --git a/utils/tui.py b/utils/tui.py index 9fb1c54..a930d03 100644 --- a/utils/tui.py +++ b/utils/tui.py @@ -20,18 +20,17 @@ from textual.widgets import ( from flows.otp import otp_activities_by_agent, otp_revoke from flows.prepPolicy import menu_policy_enforce -from flows.quietAgent import findQuietAgents from models.agent import Agent from models.policy import Policy from screens.moveagentworkflowscreen import MoveAgentWorkflowScreen from screens.otpworkflowscreen import OTPWorkflowScreen -from services.agenthandler import findAgents, moveAgents, toggleEnforcement from services.API import AirlockAPIWrapper from services.policyhandler import confirmUpdateAfromE from utils.configmanager import load_env from utils.setup import get_base_directory, load_user_config from utils.utils import open_directory from widgets.agentmoveoperations import AgentMoveOperations +from widgets.amber_terminal_theme import get_amber_terminal_theme from widgets.multiagentselector import MultiAgentSelector from widgets.OTP_generate import OTPGenerator from widgets.policytreewidget import PolicyTreeWidget @@ -105,24 +104,18 @@ class MainMenuScreen(Screen): current_tab = reactive("") BUTTON_DEFS = { - "find": [ - ("πŸ” - Device Search", "find_device_button"), + "agent_actions": [ + ( + "πŸ–₯️ - Find, Move, or Generate OTP for Agents", + "move_agent_workflow_button", + ), ("πŸ”‡ - Find Quiet Hosts", "find_quiet_button"), ], - "move": [ - ("πŸ”„ - Move Agent Workflow", "move_agent_workflow_button"), - ("βœ… - Move to local approval", "move_local_button"), - ("πŸ”„ - Move to Audit/Enforcement", "move_audit_button"), - ("πŸ”€ - Move - Other", "move_other_button"), - ], - "otp": [ - ("🎫 - Generate OTPs", "otp_generate_button"), - ("πŸ“Š - OTP Activities By Agent", "otp_activities_button"), - ("❌ - Revoke OTPs", "otp_revoke_button"), - ], "policy": [ ("πŸ”’ - Prepare Policy For Enforcement", "policy_prep_button"), ("πŸ”„ - Update Audit Policies", "policy_audit_update_button"), + ("πŸ“Š - OTP Activities By Agent", "otp_activities_button"), + ("❌ - Revoke OTPs", "otp_revoke_button"), ], } @@ -148,23 +141,21 @@ class MainMenuScreen(Screen): yield Header(show_clock=True, icon="βš™") tabs = [ - Tab("Policy Tree", id="p_tree"), - Tab("Device Search", id="find"), - Tab("Move Agent", id="move"), - Tab("OTP", id="otp"), + Tab("Tree View", id="p_tree"), + Tab("Agents", id="agent_actions"), Tab("Directory", id="dir"), Tab("Settings", id="settings"), ] if self.extras == "POLICYPREP": - tabs.insert(3, Tab("Policy Prep", id="policy")) + tabs.insert(2, Tab("Policy Prep", id="policy")) yield Tabs(*tabs, id="tabs") yield Vertical(id="content") yield Footer() def on_mount(self) -> None: - self.switch_tab("find") + self.switch_tab("agent_actions") # focus helpers def _get_content_buttons(self) -> list[Button]: @@ -225,7 +216,7 @@ class MainMenuScreen(Screen): def on_multi_agent_selector_agents_selected( self, message: MultiAgentSelector.AgentsSelected ) -> None: - """Handle selected agents from MultiAgentSelector.""" + """Handle selected agents from AgentSelector.""" global _PENDING_JOB selected_agents = message.selected_agents logger.info("Selected agents: %s", selected_agents) @@ -324,26 +315,11 @@ class MainMenuScreen(Screen): logger.debug("Button pressed: %s", button_id) match button_id: - case "find_device_button": - _PENDING_JOB = ("legacy", findAgents, (self.app.api, False), {}) - case "find_quiet_button": - _PENDING_JOB = ("legacy", findQuietAgents, (self.app.api,), {}) case "move_agent_workflow_button": # Push Move Agent workflow screen self.app.push_screen(MoveAgentWorkflowScreen(self.app.devices)) event.stop() return # Don't exit the app - case "move_local_button": - _PENDING_JOB = ( - "legacy", - print, - ("Move to local approval (placeholder)",), - {}, - ) - case "move_audit_button": - _PENDING_JOB = ("legacy", toggleEnforcement, (self.app.api,), {}) - case "move_other_button": - _PENDING_JOB = ("legacy", moveAgents, (self.app.api,), {}) case "otp_generate_button": # NEW: Push OTP workflow screen instead of legacy function self.app.push_screen(OTPWorkflowScreen(self.app.devices)) @@ -415,6 +391,7 @@ class Loxide(App): def on_mount(self, api: AirlockAPIWrapper) -> None: self.register_theme(get_retro_terminal_theme()) + self.register_theme(get_amber_terminal_theme()) self.theme = self._textual_theme self.push_screen(MainMenuScreen(api)) diff --git a/widgets/OTP_generate.py b/widgets/OTP_generate.py index a3476c6..221394b 100644 --- a/widgets/OTP_generate.py +++ b/widgets/OTP_generate.py @@ -194,7 +194,10 @@ class OTPGenerator(Widget): btn_id = event.button.id if btn_id == "back_button": - self.app.pop_screen() + + while len(self.app.screen_stack) > 2: + self.app.pop_screen() + event.stop() elif btn_id == "copy_clipboard_button": diff --git a/widgets/agentmoveoperations.py b/widgets/agentmoveoperations.py index 1e55fdf..6cfe7f3 100644 --- a/widgets/agentmoveoperations.py +++ b/widgets/agentmoveoperations.py @@ -18,9 +18,13 @@ Dependencies: - flows.localApproval: Local approval workflow handling """ +from dataclasses import asdict +from datetime import datetime import logging +import os from typing import List +import pandas as pd from textual.containers import Horizontal, Vertical from textual.css.query import NoMatches from textual.message import Message @@ -29,7 +33,9 @@ from textual.widget import Widget from textual.widgets import Button, DataTable, Header, Static, TextArea from models.agent import Agent +from screens.otpworkflowscreen import OTPWorkflowScreen from screens.policyselectorscreen import PolicySelectorScreen +from widgets.OTP_generate import OTPGenerator logger = logging.getLogger(__name__) @@ -133,18 +139,24 @@ class AgentMoveOperations(Widget): Handles NoMatches exceptions gracefully in case buttons are not yet rendered. """ try: + export_csv_btn = self.query_one("#export_csv_btn", Button) local_approval_btn = self.query_one("#local_approval_btn", Button) toggle_enforcement_btn = self.query_one("#toggle_enforcement_btn", Button) other_policy_btn = self.query_one("#other_policy_btn", Button) + otp_gen_btn = self.query_one("#otp_gen_btn", Button) # If operation in progress, disable all if self.operation_in_progress: + otp_gen_btn = True + export_csv_btn.disabled = True local_approval_btn.disabled = True toggle_enforcement_btn.disabled = True other_policy_btn.disabled = True else: - # If an operation was selected, keep it disabled, enable others + # If an operation was selected, disable if self.selected_operation: + otp_gen_btn.disabled = self.selected_operation == "otp_gen" + export_csv_btn.disabled = self.selected_operation == "export_csv" local_approval_btn.disabled = ( self.selected_operation == "local_approval" ) @@ -156,6 +168,8 @@ class AgentMoveOperations(Widget): ) else: # Enable all buttons + otp_gen_btn = False + export_csv_btn = False local_approval_btn.disabled = False toggle_enforcement_btn.disabled = False other_policy_btn.disabled = False @@ -189,21 +203,21 @@ class AgentMoveOperations(Widget): f"Operation: {operation_name}", f"{'=' * 50}", "", - f"Òœ… Successful ({len(successful)}):", + f"βœ… Successful ({len(successful)}):", ] if successful: for agent, result in successful: - results_lines.append(f" Ò€’ {agent.hostname}") + results_lines.append(f" βœ… {agent.hostname}") else: results_lines.append(" (none)") results_lines.append("") - results_lines.append(f"ҝŒ Failed ({len(unsuccessful)}):") + results_lines.append(f"❌ Failed ({len(unsuccessful)}):") if unsuccessful: for agent, error in unsuccessful: - results_lines.append(f" Ò€’ {agent.hostname}: {error}") + results_lines.append(f" ❌ {agent.hostname}: {error}") else: results_lines.append(" (none)") @@ -231,7 +245,7 @@ class AgentMoveOperations(Widget): It builds a two-column layout with: - Left side: Agent table showing selected agents and their current policies - Right side: Operation buttons and results display area - - Bottom: Navigation buttons (Back, Reset) + - Bottom: Navigation buttons (Back) The layout is responsive with: - Agent table: 2/3 width @@ -240,7 +254,7 @@ class AgentMoveOperations(Widget): """ yield Header(show_clock=True, icon="βš™") title_text = Static( - f"↔️ Move Agent Operations - {len(self.agents)} device(s) selected", + f"πŸ–₯️ Agent Operations - {len(self.agents)} device(s) selected", id="move_ops_title", ) title_text.styles.margin = (0, 0, 1, 0) @@ -251,7 +265,7 @@ class AgentMoveOperations(Widget): # Left side - Agent list with Vertical() as left_side: - left_side.styles.width = "2fr" + left_side.styles.width = "3fr" left_side.styles.height = "auto" agents_label = Static("Selected Agents:") @@ -266,7 +280,8 @@ class AgentMoveOperations(Widget): # Right side - Operation buttons with Vertical() as right_side: - right_side.styles.width = "1fr" + right_side.styles.width = "2fr" + right_side.styles.margin = (0, 1, 0, 1) right_side.styles.height = "auto" operations_label = Static("Operations:") @@ -274,13 +289,23 @@ class AgentMoveOperations(Widget): yield operations_label # Operation buttons + export_csv_btn = Button("πŸ“ˆ Export CSV", id="export_csv_btn") + export_csv_btn.styles.width = "100%" + export_csv_btn.styles.margin = (0, 0, 1, 0) + yield export_csv_btn + local_approval_btn = Button( - "βœ… Local Approval Mode", id="local_approval_btn" + "βœ”οΈ Local Approval Mode", id="local_approval_btn" ) local_approval_btn.styles.width = "100%" local_approval_btn.styles.margin = (0, 0, 1, 0) yield local_approval_btn + otp_gen_btn = Button("🎫 Generate One Time Passes", id="otp_gen_btn") + otp_gen_btn.styles.width = "100%" + otp_gen_btn.styles.margin = (0, 0, 1, 0) + yield otp_gen_btn + toggle_enforcement_btn = Button( "πŸ”„ Toggle Audit/Enforcement", id="toggle_enforcement_btn" ) @@ -300,39 +325,10 @@ class AgentMoveOperations(Widget): status_label.styles.margin = (2, 0, 0, 0) yield status_label - # Results display area (initially hidden) - with Vertical(id="results_container") as results_container: - results_container.styles.height = "auto" - results_container.styles.margin = (1, 0, 0, 0) - results_container.styles.display = "none" - - results_label = Static("Γ°ΕΈβ€œΕ  Results:", id="results_label") - results_label.styles.margin = (0, 0, 0, 0) - yield results_label - - results_text = TextArea(id="results_text", read_only=True) - results_text.styles.height = 15 - results_text.styles.margin = (0, 0, 1, 0) - yield results_text - - copy_results_btn = Button( - "Γ°ΕΈβ€œβ€Ή Copy Results to Clipboard", id="copy_results_btn" - ) - copy_results_btn.styles.width = "100%" - yield copy_results_btn - - # Bottom buttons - with Horizontal() as button_row: - button_row.styles.height = "auto" - button_row.styles.margin = (1, 0, 0, 0) - - back_button = Button("Ò† Back", id="back_button") - back_button.styles.width = "1fr" - yield back_button - - reset_button = Button("Γ°ΕΈβ€β€ž Reset Selection", id="reset_button") - reset_button.styles.width = "1fr" - yield reset_button + back_button = Button("← Back", id="back_button") + back_button.styles.width = "50%" + back_button.styles.margin = (0, 1, 1, 0) + yield back_button def on_mount(self) -> None: """ @@ -359,13 +355,15 @@ class AgentMoveOperations(Widget): self._update_button_states() + def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None: + """Handle OTP generation request - call the actual OTP generation function.""" + def on_button_pressed(self, event: Button.Pressed): """ Handle button press events from the widget. This Textual event handler routes button presses to appropriate actions: - back_button: Pop this screen (return to parent) - - reset_button: Clear operation state and hide results - copy_results_btn: Copy results text to clipboard (requires pyperclip) - local_approval_btn: Start local approval operation - toggle_enforcement_btn: Start toggle audit/enforcement operation @@ -380,21 +378,8 @@ class AgentMoveOperations(Widget): btn_id = event.button.id if btn_id == "back_button": - self.app.pop_screen() - event.stop() - - elif btn_id == "reset_button": - # Reset operation selection - self.selected_operation = "" - self.operation_in_progress = False - status_label = self.query_one("#status_label", Static) - status_label.update("") - # Hide results - try: - results_container = self.query_one("#results_container", Vertical) - results_container.styles.display = "none" - except NoMatches: - pass + while len(self.app.screen_stack) > 2: + self.app.pop_screen() event.stop() elif btn_id == "copy_results_btn": @@ -404,18 +389,21 @@ class AgentMoveOperations(Widget): pyperclip.copy(results_text.text) self.app.notify( - "Òœ… Results copied to clipboard!", + "πŸ“‹βœ… Results copied to clipboard!", severity="information", timeout=2, ) except ImportError: self.app.notify( - "Òő ï¸ pyperclip not installed. Run: pip install pyperclip", + "❌ pyperclip not installed. Run: pip install pyperclip", severity="warning", ) except Exception as e: self.app.notify(f"ҝŒ Failed to copy: {str(e)}", severity="error") event.stop() + elif btn_id == "export_csv_btn": + self._start_export_csv_operation() + event.stop() elif btn_id == "local_approval_btn": self._start_local_approval_operation() @@ -428,6 +416,9 @@ class AgentMoveOperations(Widget): elif btn_id == "other_policy_btn": self._start_other_policy_operation() event.stop() + elif btn_id == "otp_gen_btn": + self._start_OTP_gen_operation() + event.stop() def _start_local_approval_operation(self) -> None: """ @@ -456,7 +447,7 @@ class AgentMoveOperations(Widget): self.operation_in_progress = True status_label = self.query_one("#status_label", Static) - status_label.update("ҏ³ Moving agents to local approval...") + status_label.update("βœ”οΈ Moving agents to local approval...") # Get API from app api = self.app.api @@ -491,12 +482,12 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error during local approval operation: {e}") - status_label.update(f"ҝŒ Error: {str(e)}") + status_label.update(f"❌ Error: {str(e)}") self.operation_in_progress = False return self.operation_in_progress = False - status_label.update("Òœ… Operation complete!") + status_label.update("βœ… Operation complete!") # Display results in the widget self._display_results("Local Approval Mode", successful, unsuccessful) @@ -508,14 +499,63 @@ class AgentMoveOperations(Widget): ) ) + def _start_export_csv_operation(self) -> None: + self.selected_operation = "export_csv" + self.operation_in_progress = True + successful = [] + unsuccessful = [] + status_label = self.query_one("#status_label", Static) + status_label.update("Exporting CSV...") + agents = self.agents + policies = self.app.policies + path = self.app.working_dir + + try: + # Enrich each agent with policies and status text + for agent in agents: + agent.enrich_with_policies(policies) + + # Convert each Agent to a dictionary, including all fields + data = [] + for agent in agents: + row = asdict(agent) + # Remove the class-level status_map from the row + row.pop("status_map", None) + data.append(row) + + # Create DataFrame + df = pd.DataFrame(data) + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"agentsearch_{timestamp}.csv" + file_path = os.path.join(str(path), filename) + df.to_csv(file_path, index=False) + successful.append(file_path) + status_label.update(f"βœ… Exported to {file_path}") + except Exception: + status_label.update("❌ Failed") + + self.operation_in_progress = False + + """ + # Display results in the widget + self._display_results("CSV Export", successful, unsuccessful) + + # Also post message for potential parent handling + self.post_message( + self.OperationComplete( + "CSV Export", self.agents, successful, unsuccessful + ) + ) + """ + def _start_toggle_enforcement_operation(self) -> None: """ Toggle agents between enforcement and audit policy modes. This operation intelligently switches each agent between enforcement and audit modes based on its current state: - - If agent.groupid is in POLICY_MAP_ENF_AUD: currently enforcing Ò†' move to audit - - Otherwise: currently in audit Ò†' move to enforcement + - If agent.groupid is in POLICY_MAP_ENF_AUD: currently enforcing , move to audit + - Otherwise: currently in audit, move to enforcement The operation: - Retrieves the enforcement/audit policy relationship map from protected config @@ -570,7 +610,7 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error during toggle enforcement operation: {e}") - status_label.update(f"ҝŒ Error: {str(e)}") + status_label.update(f"❌ Error: {str(e)}") self.operation_in_progress = False return @@ -640,11 +680,17 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error loading policies: {e}") - status_label.update(f"Error: {str(e)}") + status_label.update(f"❌ Error: {str(e)}") self.operation_in_progress = False self.selected_operation = "" self.app.notify(f"Failed to load policies: {str(e)}", severity="error") + def _start_OTP_gen_operation(self) -> None: + status_label = self.query_one("#status_label", Static) + status_label.update("Generating OTP.") + + self.app.push_screen(OTPWorkflowScreen(self.agents)) + def _execute_move_to_policy(self, target_policy) -> None: """ Execute the actual move of agents to the selected policy. diff --git a/widgets/amber_terminal_theme.py b/widgets/amber_terminal_theme.py new file mode 100644 index 0000000..169c325 --- /dev/null +++ b/widgets/amber_terminal_theme.py @@ -0,0 +1,35 @@ +from textual.color import Color +from textual.theme import Theme + + +def get_amber_terminal_theme(): + """Amber CRT theme with compensated brightness for blending.""" + return Theme( + name="amber-terminal", + background=Color.parse("#000000"), # pure black + primary=Color.parse("#ffb733"), # bright amber + secondary=Color.parse("#e69500"), # strong amber + success=Color.parse("#ffb733"), + warning=Color.parse("#ffff66"), + error=Color.parse("#ff3300"), + surface=Color.parse("#3a1f00"), # brighter brown for blending + ) + + +AMBER_TERMINAL_CSS = """ +Screen { + align: center middle; + background: #000000; /* force black */ + color: #ffb733; /* force amber text */ +} + +.widget { + border: tall #ffb733; /* force amber border */ + background: #3a1f00; /* compensated surface */ + width: 80%; +} + +* { + font-family: "Courier New", monospace; +} +""" diff --git a/widgets/multiagentselector.py b/widgets/multiagentselector.py index dd0fe1b..a6c97e0 100644 --- a/widgets/multiagentselector.py +++ b/widgets/multiagentselector.py @@ -40,7 +40,7 @@ class MultiAgentSelector(Widget): def compose(self): yield Header(show_clock=True, icon="βš™") - title_text = Static("πŸ–§ Multi-Agent Selector", id="selector_title") + title_text = Static("πŸ–§ Agent Selector", id="selector_title") title_text.styles.margin = (0, 0, 0, 1) yield title_text diff --git a/widgets/policyselector.py b/widgets/policyselector.py index 1796968..c5bc6f7 100644 --- a/widgets/policyselector.py +++ b/widgets/policyselector.py @@ -91,7 +91,7 @@ class PolicySelector(Widget): - Clear Filter button - Confirm Selection button - Policy table displaying available policies - - Back and Continue buttons for navigation + - Back buttons for navigation """ yield Header(show_clock=True, icon="βš™") title_text = Static( @@ -144,6 +144,11 @@ class PolicySelector(Widget): selected_label.styles.margin = (2, 0, 1, 0) yield selected_label + cancel_button = Button("← Back", id="back_button") + cancel_button.styles.width = "100%" + cancel_button.styles.margin = (1, 0, 1, 0) + yield cancel_button + # Right side - Policy table with Vertical() as right_side: right_side.styles.width = "2fr" @@ -158,23 +163,6 @@ class PolicySelector(Widget): policy_table.styles.margin = (1, 0, 1, 0) yield policy_table - # Bottom buttons - with Horizontal() as button_row: - button_row.styles.height = "auto" - button_row.styles.margin = (1, 0, 0, 0) - - cancel_button = Button("βœ• Cancel", id="back_button", variant="error") - cancel_button.styles.width = "1fr" - yield cancel_button - - continue_button = Button( - "β–Ά Continue", - id="continue_button", - variant="primary", - ) - continue_button.styles.width = "1fr" - continue_button.styles.margin = (0, 0, 0, 1) - yield continue_button yield Footer() def on_mount(self) -> None: @@ -239,7 +227,6 @@ class PolicySelector(Widget): - filter_button (Apply Filter): Filter policies with wildcard support - clear_filter_button: Clear filter and show all policies - confirm_button: Confirm selection and post message - - continue_button: Continue without posting message Args: event (Button.Pressed): The button press event. @@ -247,7 +234,8 @@ class PolicySelector(Widget): btn_id = event.button.id if btn_id == "back_button": - self.app.pop_screen() + while len(self.app.screen_stack) > 2: + self.app.pop_screen() event.stop() elif btn_id == "filter_button": @@ -262,10 +250,6 @@ class PolicySelector(Widget): self._confirm_selection() event.stop() - elif btn_id == "continue_button": - self.app.pop_screen() - event.stop() - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: """ Handle row selection in the policy table. diff --git a/widgets/retro_terminal_theme.py b/widgets/retro_terminal_theme.py index 3ff0d02..e0cd9c9 100644 --- a/widgets/retro_terminal_theme.py +++ b/widgets/retro_terminal_theme.py @@ -12,7 +12,7 @@ def get_retro_terminal_theme(): success=Color.parse("#00ff00"), warning=Color.parse("#ffff00"), error=Color.parse("#ff0000"), - surface=Color.parse("#111111"), + surface=Color.parse("#071802"), ) diff --git a/widgets/themeselector.py b/widgets/themeselector.py index 6a423c2..bcbbb99 100644 --- a/widgets/themeselector.py +++ b/widgets/themeselector.py @@ -26,7 +26,8 @@ class ThemeSelector(Widget): ("Flexoki", "flexoki"), ("Catppuccin Latte", "catppuccin-latte"), ("Solarized Light", "solarized-light"), - ("Retro Terminal", "retro-terminal"), # your custom theme + ("Retro Terminal", "retro-terminal"), + ("Amber Terminal", "amber-terminal"), # your custom theme ] def compose(self): From 3c2e825210377f2cbf9b61ae9c459ca2db5cb7ec Mon Sep 17 00:00:00 2001 From: Zarithas Date: Mon, 10 Nov 2025 15:47:30 -0500 Subject: [PATCH 08/29] Minor UI Tweak --- models/policy.py | 31 +++++++++++++++++-------------- widgets/multiagentselector.py | 8 ++++---- widgets/policyselector.py | 4 +++- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/models/policy.py b/models/policy.py index dbfb357..09e16e7 100644 --- a/models/policy.py +++ b/models/policy.py @@ -13,33 +13,36 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json """ Policy model representing policy data and relationships. """ -class Policy: - def __init__(self, groupid, hidden, name, parent): - self.groupid = groupid - self.hidden = hidden - self.name = name - self.parent = parent +# policy.py - def __repr__(self): - # Show all current attributes, including dynamically added ones +from dataclasses import asdict, dataclass, field +import json +from typing import Optional + + +@dataclass(order=True) +class Policy: + name: str + groupid: int = field(compare=False) + hidden: bool = field(compare=False) + parent: Optional[str] = field(default=None, compare=False) + + def __repr__(self) -> str: attrs = ", ".join( f"{key}={repr(value)}" for key, value in self.__dict__.items() ) return f"" - def to_dict(self): - # Return all attributes as a dictionary - return self.__dict__ + def to_dict(self) -> dict: + return asdict(self) - def to_json(self): - # Convert to JSON string, handling non-serializable types gracefully + def to_json(self) -> str: return json.dumps(self.to_dict(), default=str) diff --git a/widgets/multiagentselector.py b/widgets/multiagentselector.py index a6c97e0..92016b7 100644 --- a/widgets/multiagentselector.py +++ b/widgets/multiagentselector.py @@ -79,14 +79,14 @@ class MultiAgentSelector(Widget): with Horizontal() as select_buttons: select_buttons.styles.margin = (0, 0, 0, 0) - select_all_button = Button("βœ… Select All", id="select_all") - select_all_button.styles.margin = (1, 1, 0, 1) - yield select_all_button - select_none_button = Button("🚫 Select None", id="select_none") select_none_button.styles.margin = (1, 0, 0, 1) yield select_none_button + select_all_button = Button("βœ… Select All", id="select_all") + select_all_button.styles.margin = (1, 1, 0, 1) + yield select_all_button + with Horizontal() as button_row: button_row.styles.height = "auto" button_row.styles.margin = (1, 0, 0, 0) diff --git a/widgets/policyselector.py b/widgets/policyselector.py index c5bc6f7..fb34218 100644 --- a/widgets/policyselector.py +++ b/widgets/policyselector.py @@ -108,6 +108,7 @@ class PolicySelector(Widget): with Vertical() as left_side: left_side.styles.width = "1fr" left_side.styles.height = "auto" + left_side.styles.margin = (0, 1, 0, 1) filter_label = Static("Filter Policies:") filter_label.styles.margin = (0, 0, 0, 0) @@ -130,7 +131,7 @@ class PolicySelector(Widget): apply_button.styles.margin = (0, 0, 1, 0) yield apply_button - clear_button = Button("πŸ—‘οΈ Clear Filter", id="clear_filter_button") + clear_button = Button("Clear Filter", id="clear_filter_button") clear_button.styles.width = "100%" clear_button.styles.margin = (0, 0, 1, 0) yield clear_button @@ -190,6 +191,7 @@ class PolicySelector(Widget): policies_list = self.policies.to_dict("records") else: policies_list = self.policies + policies_list = sorted(policies_list) self._filtered_policies = [] self._displayed_policies = [] # Initialize displayed list From 5cb079dad74f864b222b88f05dac8ab5d4ebe8f4 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Mon, 10 Nov 2025 17:21:18 -0500 Subject: [PATCH 09/29] Fixed Breaking Legacy change --- utils/tui.py | 71 ++++++++++++++-------------------- widgets/agentmoveoperations.py | 5 ++- widgets/multiagentselector.py | 5 ++- 3 files changed, 36 insertions(+), 45 deletions(-) diff --git a/utils/tui.py b/utils/tui.py index a930d03..8cadf33 100644 --- a/utils/tui.py +++ b/utils/tui.py @@ -20,6 +20,7 @@ from textual.widgets import ( from flows.otp import otp_activities_by_agent, otp_revoke from flows.prepPolicy import menu_policy_enforce +from flows.quietAgent import findQuietAgents from models.agent import Agent from models.policy import Policy from screens.moveagentworkflowscreen import MoveAgentWorkflowScreen @@ -325,6 +326,8 @@ class MainMenuScreen(Screen): self.app.push_screen(OTPWorkflowScreen(self.app.devices)) event.stop() return # Don't exit the app + case "find_quiet_button": + _PENDING_JOB = ("legacy", findQuietAgents, (self.app.api,), {}) case "otp_activities_button": _PENDING_JOB = ("legacy", otp_activities_by_agent, (self.app.api,), {}) case "otp_revoke_button": @@ -353,7 +356,6 @@ class Loxide(App): text-align: center; } """ - BINDINGS = [ ("q", "quit", "Quit"), ("d", "open_dir", "Open Directory"), @@ -367,17 +369,20 @@ class Loxide(App): if not os.path.isdir(wd): wd = os.getcwd() self.working_dir = wd + # Initial data load + self.refresh_data() - # Add error handling for API calls + def refresh_data(self) -> None: + """Public method to refresh policies and devices from the API.""" try: self.policies = [ - Policy(**row.to_dict()) for _, row in api.policy_find_all().iterrows() + Policy(**row.to_dict()) + for _, row in self.api.policy_find_all().iterrows() ] self.devices = [ - Agent(**row.to_dict()) for _, row in api.agent_find_all().iterrows() + Agent(**row.to_dict()) + for _, row in self.api.agent_find_all().iterrows() ] - - # Enrich agents with policy information if self.policies and self.devices: for agent in self.devices: agent.enrich_with_policies(self.policies) @@ -401,6 +406,8 @@ class Loxide(App): self.exit() def action_open_dir(self) -> None: + # Refresh data before proceeding + self.refresh_data() screen = self.screen_stack[-1] if isinstance(screen, MainMenuScreen): if screen.current_tab != "dir": @@ -417,7 +424,6 @@ def _restore_terminal_for_legacy() -> None: sys.stdout.write("\033[?1000l\033[?1002l\033[?1003l\033[?1006l") sys.stdout.write("\033[2J\033[H") sys.stdout.flush() - if os.name == "nt": try: import ctypes @@ -434,7 +440,6 @@ def _restore_terminal_for_legacy() -> None: def _run_legacy_job(func, args, kwargs) -> None: logger.debug("Running legacy job: %s", getattr(func, "__name__", func)) _restore_terminal_for_legacy() - try: func(*args, **kwargs) finally: @@ -449,24 +454,31 @@ def _run_legacy_job(func, args, kwargs) -> None: # --------------------------------------------------------------------------- def run_Loxide(api: AirlockAPIWrapper) -> None: global _PENDING_JOB + base_dir = get_base_directory() + env_path = base_dir / ".env" + dotenv.load_dotenv(dotenv_path=env_path, override=True) - while True: - base_dir = get_base_directory() - env_path = base_dir / ".env" - dotenv.load_dotenv(dotenv_path=env_path, override=True) + max_attempts = 5 + attempts = 0 + while attempts < max_attempts: + attempts += 1 + logger.debug("Starting job loop iteration (attempt %d)", attempts) _PENDING_JOB = None app = Loxide(api) try: app.run() except SystemExit as exc: - logger.debug("Caught SystemExit from Textual: %s", exc) + if exc.code != 0: + logger.debug("Caught SystemExit from Textual: %s", exc) + raise job = _PENDING_JOB logger.debug("After app.run(), _PENDING_JOB = %r", job) if not job: + logger.debug("No job pending, exiting loop") break if job[0] == "legacy": @@ -475,49 +487,24 @@ def run_Loxide(api: AirlockAPIWrapper) -> None: continue if job[0] == "restart": - # just loop again; fresh .env was already loaded at the top + logger.debug("Restarting job loop") continue if job[0] == "multi_agent_action": - # Handle multi-agent selection logger.info("Multi-agent action with selected agents: %s", job[1]) continue - # NEW: Handle OTP workflow if job[0] == "otp_workflow": _, devices, requestor, reasoning, duration = job - # Call your OTP generation with the parameters def otp_generate_with_params(): - - print(f"\n{'='*60}") - print("OTP GENERATION") - print(f"{'='*60}") - print(f"Requestor: {requestor}") - print(f"Reasoning: {reasoning}") - print(f"Duration: {duration} minutes") - print(f"\nGenerating OTPs for {len(devices)} devices:") - print(f"{'='*60}\n") - - # Call your actual OTP generation function - # You'll need to adapt otp_generate to accept these parameters - # For now, this is a placeholder showing the structure - for device in devices: - print(f"Device: {device}") - print(f" Requestor: {requestor}") - print(f" Reason: {reasoning}") - print(f" Duration: {duration} minutes") - # TODO: Actually call your API to generate OTP - # result = api.generate_otp(device, requestor, reasoning, duration) - print() - - print(f"{'='*60}") - print("OTP Generation Complete!") - print(f"{'='*60}") + # Your OTP logic here + pass _run_legacy_job(otp_generate_with_params, (), {}) continue + logger.error("Unknown job type: %r", job) break diff --git a/widgets/agentmoveoperations.py b/widgets/agentmoveoperations.py index 6cfe7f3..03e5da4 100644 --- a/widgets/agentmoveoperations.py +++ b/widgets/agentmoveoperations.py @@ -506,6 +506,7 @@ class AgentMoveOperations(Widget): unsuccessful = [] status_label = self.query_one("#status_label", Static) status_label.update("Exporting CSV...") + self.app.refresh_data() agents = self.agents policies = self.app.policies path = self.app.working_dir @@ -604,6 +605,8 @@ class AgentMoveOperations(Widget): successful.append((agent, f"Moved to {mode}: {result}")) logger.info(f"Successfully toggled {agent.hostname} to {mode}") + self.app.refresh_data() + except Exception as e: unsuccessful.append((agent, str(e))) logger.error(f"Failed to toggle {agent.hostname}: {e}") @@ -728,7 +731,7 @@ class AgentMoveOperations(Widget): status_label.update(f"Error: {str(e)}") self.operation_in_progress = False return - + self.app.refresh_data() self.operation_in_progress = False status_label.update("Operation complete!") diff --git a/widgets/multiagentselector.py b/widgets/multiagentselector.py index 92016b7..f4ea371 100644 --- a/widgets/multiagentselector.py +++ b/widgets/multiagentselector.py @@ -80,11 +80,11 @@ class MultiAgentSelector(Widget): select_buttons.styles.margin = (0, 0, 0, 0) select_none_button = Button("🚫 Select None", id="select_none") - select_none_button.styles.margin = (1, 0, 0, 1) + select_none_button.styles.margin = (1, 1, 0, 1) yield select_none_button select_all_button = Button("βœ… Select All", id="select_all") - select_all_button.styles.margin = (1, 1, 0, 1) + select_all_button.styles.margin = (1, 0, 0, 1) yield select_all_button with Horizontal() as button_row: @@ -93,6 +93,7 @@ class MultiAgentSelector(Widget): back_button = Button("← Back", id="back_button") back_button.styles.width = "1fr" + back_button.styles.margin = (0, 0, 0, 1) yield back_button submit_button = Button( From e13382626b3441324ae55334ad003527e2fc93ea Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Tue, 11 Nov 2025 12:19:09 -0500 Subject: [PATCH 10/29] Adjusted API Timeout to 5 Minutes --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/services.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index beceb5a..5a74e53 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -17,7 +17,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "2.0.1" +version = "2.0.2" dependencies = [ "chrono", "indicatif", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 464e271..2b4069f 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "2.0.1" +version = "2.0.2" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 37f7b97..5a3a3fd 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "2.0.1" +version = "2.0.2" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index cfb5345..e4d3e02 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -205,7 +205,7 @@ fn build_client(py: Python<'_>, py_self: &Py) -> Client { Client::builder() .danger_accept_invalid_certs(true) .default_headers(header_map) - .timeout(std::time::Duration::from_secs(120)) + .timeout(std::time::Duration::from_secs(300)) .build() .unwrap() } From d9fd0232dec866b28645a2e0b2edd16ef6e723ce Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Tue, 11 Nov 2025 16:07:26 -0500 Subject: [PATCH 11/29] Reverted back to version 2.0.0 Adjusted timeout in version 2.0.0 to 5 minutes --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index 5a74e53..a3d9b52 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -17,7 +17,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "2.0.2" +version = "2.0.0" dependencies = [ "chrono", "indicatif", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 2b4069f..3a853d9 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "2.0.2" +version = "2.0.0" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 5a3a3fd..e7cd8aa 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "2.0.2" +version = "2.0.0" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } From 0a0f39542dbbd994a9b5f53e5dfb025649e4f91b Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Tue, 11 Nov 2025 16:10:30 -0500 Subject: [PATCH 12/29] Reverted requirements.txt to include version 2.0.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4585b52..65ee4c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==2.0.1 \ No newline at end of file +airlock_libs==2.0.0 \ No newline at end of file From f0e77db4144336225fb808444ebe1d0751d5693f Mon Sep 17 00:00:00 2001 From: Zarithas Date: Thu, 13 Nov 2025 11:45:13 -0500 Subject: [PATCH 13/29] Added OTP Activity Review WIP --- AirlockTools_Client.py => Loxide.py | 0 flows/otp.py | 38 -- screens/moveagentworkflowscreen.py | 4 +- screens/otpactivityscreen.py | 678 ++++++++++++++++++++ screens/otpworkflowscreen.py | 4 +- {widgets => themes}/amber_terminal_theme.py | 0 {widgets => themes}/retro_terminal_theme.py | 0 utils/tui.py | 56 +- widgets/OTP_generate.py | 10 +- widgets/multiagentselector.py | 4 +- widgets/policytreewidget.py | 67 +- 11 files changed, 787 insertions(+), 74 deletions(-) rename AirlockTools_Client.py => Loxide.py (100%) create mode 100644 screens/otpactivityscreen.py rename {widgets => themes}/amber_terminal_theme.py (100%) rename {widgets => themes}/retro_terminal_theme.py (100%) diff --git a/AirlockTools_Client.py b/Loxide.py similarity index 100% rename from AirlockTools_Client.py rename to Loxide.py diff --git a/flows/otp.py b/flows/otp.py index 8b15fab..720aab3 100644 --- a/flows/otp.py +++ b/flows/otp.py @@ -29,44 +29,6 @@ from utils.utils import colorText, get_sanitized_input logger = logging.getLogger(__name__) -def otp_generate(api: AirlockAPIWrapper): - otp_dict = {} - agents = selectAgents(api) - print(colorText("Would you like to continue with these devices?", "white")) - for agent in agents: - print(agent.hostname) - confirm = Selector.confirm() - if agents and confirm: - requester = get_sanitized_input("Who is requesting the OTP: ") - because = get_sanitized_input("Why/What work are they doing?: ") - - purpose = f"Requester: {requester} - for : {because}" - possible_durations = [15, 60, 360, 1440, 10080] - - print(colorText("Please select a duration in minutes: ", "white")) - print( - colorText( - "15 mins, 60 mins, 360 mins(6 Hours), 1440 mins (24 Hours), 10080 mins (7 Days):", - "white", - ) - ) - duration_selected = Selector.select_int(possible_durations) - - if isinstance(duration_selected, list): - duration_selected = duration_selected[0] if duration_selected else None - - if duration_selected is not None: - for agent in agents: - logging.info(f"Querying API for {agent.hostname}") - otp_code = api.otp_generate(agent.agentid, duration_selected, purpose) - logger.debug(f"Generated OTP for {agent.hostname}: {otp_code}") - otp_dict[agent.hostname] = otp_code - - print(colorText("Requested Codes:", "green")) - for key, value in otp_dict.items(): - print(colorText(f"{key} | {value}", "green")) - - def otp_activities_by_agent(api: AirlockAPIWrapper): activeagents = api.otp_find_active() awaitingagents = api.otp_find_awaiting() diff --git a/screens/moveagentworkflowscreen.py b/screens/moveagentworkflowscreen.py index 7199f55..da96331 100644 --- a/screens/moveagentworkflowscreen.py +++ b/screens/moveagentworkflowscreen.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Optional from textual.app import ComposeResult from textual.screen import Screen @@ -12,7 +12,7 @@ from widgets.resultsdisplay import ResultsDisplay class MoveAgentWorkflowScreen(Screen): """Screen that handles the agent movement workflow.""" - def __init__(self, all_agents: List[Agent]): + def __init__(self, all_agents: Optional[List[Agent]]): super().__init__() self.all_agents = all_agents self.selected_agents = None diff --git a/screens/otpactivityscreen.py b/screens/otpactivityscreen.py new file mode 100644 index 0000000..cc98655 --- /dev/null +++ b/screens/otpactivityscreen.py @@ -0,0 +1,678 @@ +from __future__ import annotations + +from datetime import datetime +import logging +import os + +import pandas as pd +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import Button, DataTable, Footer, Header, Static + +from utils.configmanager import load_env + +logger = logging.getLogger(__name__) + + +def _load_working_dir() -> str: + """ + Load the working directory from environment variables or use the current working directory. + """ + wd = os.environ.get("WORKING_DIR") + if wd: + return wd + return os.getcwd() + + +class OTPActivitiesWidget(Static): + """ + Reusable widget that contains the sessions table (left) and an Activity Preview (right). + The right side shows an Activity Preview that takes ~75% vertical space, and a lower area + with Back and Continue buttons. The Continue button pushes ActivityDetailScreen with the + currently-loaded activities. + """ + + DEFAULT_CSS = """ + OTPActivitiesWidget { + height: 1fr; + } + #main_row { + width: 100%; + height: 100%; + layout: horizontal; + } + #left_panel { + width: 60%; + min-width: 60; + border: none; + } + #right_panel { + width: 40%; + min-width: 40; + border: none; + layout: vertical; + } + #activity_preview_container { + height: 75%; + border: none; + padding: 1 1; + } + #activity_buttons { + height: 25%; + padding: 1 1; + content-align: center middle; + } + """ + + def compose(self) -> ComposeResult: + # Layout: horizontal main row with left & right panels + with Horizontal(id="main_row"): + # Left: sessions area + with Vertical(id="left_panel"): + yield Static("OTP Sessions", classes="panel-title") + with Vertical(id="sessions_table_container"): + self.sessions_table = DataTable(id="sessions_table") + self.sessions_table.styles.width = "100%" + yield self.sessions_table + # Right: Activity Preview (top 3/4) + buttons (bottom 1/4) + with Vertical(id="right_panel"): + # Activity preview area (takes ~75% of right panel) + yield Static("Activity Preview", classes="panel-title") + with Vertical(id="activity_preview_container"): + self.activities_table = DataTable(id="activity_preview_table") + yield self.activities_table + # Buttons area at the bottom (Back, Continue) + with Horizontal(id="activity_buttons"): + # Back takes left side, Continue right side + self.back_btn = Button("Back", id="activity_back_btn") + self.continue_btn = Button("Continue", id="activity_continue_btn") + # Stretch buttons nicely + self.back_btn.styles.width = "50%" + self.continue_btn.styles.width = "50%" + yield self.back_btn + yield self.continue_btn + + async def on_mount(self) -> None: + # Configure sessions table and activities preview + self.sessions_table.clear() + self.sessions_table.add_columns( + "otpid", "hostname", "status", "purpose", "granted" + ) + self.activities_table.clear() + # activities_table columns are dynamically added when activities are loaded. + # Selection behavior + self.sessions_table.cursor_type = "row" + try: + self.sessions_table.zebra_stripes = True + except Exception: + pass + self.activities_table.cursor_type = "row" + try: + self.activities_table.zebra_stripes = True + except Exception: + pass + # Store state + self._sessions_df: pd.DataFrame | None = None + self._activities_df: pd.DataFrame | None = None + self._selected_session_otpid: str | int | None = None + + async def on_button_pressed(self, event) -> None: # type: ignore[override] + """ + Handle Back / Continue buttons for the Activity Preview area. + """ + # Try to resolve the button object from the event + btn = ( + getattr(event, "button", None) + or getattr(event, "sender", None) + or getattr(event, "control", None) + or getattr(event, "widget", None) + ) + btn_id = ( + getattr(btn, "id", None) + or getattr(event, "button_id", None) + or getattr(event, "id", None) + ) + + # ---- Back ---- + if btn is self.back_btn or btn_id == getattr(self.back_btn, "id", None): + while len(self.app.screen_stack) > 2: + self.app.pop_screen() + event.stop() + return + + # ---- Continue ---- + if btn is self.continue_btn or btn_id == getattr(self.continue_btn, "id", None): + if self._activities_df is None or self._activities_df.empty: + logger.info("Continue pressed but no activities loaded.") + await self.post_message( + Static("No activities loaded to continue with.") + ) + return + # Copy activities DataFrame to pass to new screen + activities_copy = self._activities_df.copy() + otpid = self._selected_session_otpid + # Optionally include hostname if available + hostname = None + try: + if self._sessions_df is not None: + df = self._sessions_df.reset_index(drop=True) + match = df[df["otpid"] == otpid] + if not match.empty: + hostname = match.iloc[0].get("hostname") + except Exception: + hostname = None + # Create and push ActivityDetailScreen, handing the data + try: + detail_screen = ActivityDetailScreen( + activities_copy, otpid=otpid, hostname=hostname + ) + await self.app.push_screen(detail_screen) + except Exception as exc: + logger.exception("Failed to push ActivityDetailScreen: %s", exc) + return + + # Unknown button on widget + logger.debug( + "Unhandled OTPActivitiesWidget button pressed (resolved btn=%r, id=%r)", + btn, + btn_id, + ) + + async def on_data_table_row_selected(self, event) -> None: # type: ignore[override] + """ + Robust handler for DataTable row-selection across Textual micro-versions. + Tries many attribute names and shapes: + - numeric index (row_key, row_index, index) + - coordinate object or tuple (coordinate.row or (row, col)) + - direct row values (row, values, cells) -> we try to map those back to the sessions DF + - table.cursor_row fallback + """ + # 1) Determine the sending table (best-effort) + sender = None + for attr in ("sender", "table", "data_table", "control"): + sender = getattr(event, attr, None) + if sender is not None: + break + if sender is None: + sender = self.sessions_table # Assume sessions_table if unknown + # Only respond to selections in the sessions table + if sender is not self.sessions_table: + return + + # Helper to log and return + def _bad(msg: str, *args): + logger.warning(msg, *args) + return None + + # 2) Try to extract a numeric index + row_key = None + for attr in ("row_key", "row", "row_index", "index"): + row_key = getattr(event, attr, None) + if row_key is not None: + break + + # If coordinate: try to extract .row or tuple[0] + if row_key is None: + coord = getattr(event, "coordinate", None) or getattr( + event, "cursor_coordinate", None + ) + if coord is not None: + if hasattr(coord, "row"): + row_key = coord.row + elif isinstance(coord, (tuple, list)) and len(coord) >= 1: + row_key = coord[0] + + # If still nothing, maybe the event provides the row's cell values directly + row_values = None + for attr in ("values", "cells", "row", "row_values", "selected_row_values"): + val = getattr(event, attr, None) + if val: + # Prefer actual sequence of cell values + row_values = val + break + + # If we have row_values, try to map them back to the sessions DataFrame + if row_values is not None: + # Normalize into list of strings for comparison + try: + vals = [ + "" if pd.isna(v) else str(v) + for v in ( + list(row_values) + if not isinstance(row_values, str) + else [row_values] + ) + ] + except Exception: + vals = [str(row_values)] + # Try to match against the expected columns order we render + if self._sessions_df is None or self._sessions_df.empty: + logger.warning( + "Sessions DataFrame is empty; cannot map selected row values." + ) + return + df_ordered = self._sessions_df.reset_index(drop=True) + expected_cols = ["otpid", "hostname", "status", "purpose", "granted"] + + # Build stringified candidates for each row in df using the same columns we show + def _row_to_vals(sr): + out = [] + for c in expected_cols: + if c in sr: + v = sr[c] + out.append("" if pd.isna(v) else str(v)) + else: + out.append("") + return out + + match_idx = None + for i, sr in df_ordered.iterrows(): + cand = _row_to_vals(sr) + # Compare prefix: row values might be a subset (e.g. only first 3 cols), so compare prefix only + if len(vals) <= len(cand) and all( + vals[j] == cand[j] for j in range(len(vals)) + ): + match_idx = i + break + if match_idx is None: + # Try looser match: compare first cell only (otpid) + first = vals[0] if vals else None + if first is not None: + for i, sr in df_ordered.iterrows(): + cand0 = "" if pd.isna(sr.get("otpid")) else str(sr.get("otpid")) + if cand0 == first: + match_idx = i + break + if match_idx is None: + logger.warning( + "Unable to locate DataFrame row matching selected row values: %r", + vals, + ) + return + idx = int(match_idx) + else: + # 3) If we have a row_key, try to normalize to an int index + if row_key is not None: + try: + idx = int(row_key) + except Exception: + # Try converting via string + try: + idx = int(str(row_key)) + except Exception: + idx = None + if idx is None: + # Final numeric fallback: use sessions_table.cursor_row if present + try: + idx = getattr(self.sessions_table, "cursor_row") + except Exception: + idx = None + if idx is None: + _bad("Failed to normalize row/key from event: %r", row_key) + return + else: + # 4) Try table cursor_row as last resort + try: + idx = getattr(self.sessions_table, "cursor_row") + except Exception: + logger.warning( + "Could not determine selected row from event: %r", event + ) + # Helpful debug hint for you to paste back if still failing: + logger.debug("Event repr for debugging: %r", event) + return + + # At this point we should have an integer idx + try: + idx = int(idx) + except Exception: + logger.exception( + "Final normalization of selected row index failed: %r", idx + ) + return + + # Validate sessions df + if self._sessions_df is None or self._sessions_df.empty: + logger.warning("Sessions DataFrame empty; nothing to select.") + return + df_ordered = self._sessions_df.reset_index(drop=True) + if idx < 0 or idx >= len(df_ordered): + logger.warning( + "Selected row index %s out of range (0..%d)", idx, len(df_ordered) - 1 + ) + return + row_series = df_ordered.iloc[idx] + otpid = row_series.get("otpid") + hostname = row_series.get("hostname") + # Store selected session and fetch activities + self._selected_session_otpid = otpid + # Obtain api from app (try multiple places) + api = ( + getattr(self.app, "api", None) + or getattr(self, "api", None) + or getattr(self.app, "airlock_api", None) + ) + if api is None: + logger.error("No API available on self.app.api - cannot fetch activities") + return + logger.info( + "Fetching activities for otpid=%s host=%s (selected row=%s)", + otpid, + hostname, + idx, + ) + await self._fetch_activities_for_otpid(api, otpid, hostname=hostname) + + async def load_sessions_from_api(self, api) -> None: + """ + Pulls OTP session lists, adds status column, concatenates and populates the sessions table. + """ + try: + active = api.otp_find_active() + awaiting = api.otp_find_awaiting() + enforced = api.otp_find_enforced() + revoked = api.otp_find_revoked() + except Exception as exc: + logger.exception("Failed to fetch OTP session lists: %s", exc) + # Present empty + active = awaiting = enforced = revoked = pd.DataFrame() + + # Ensure DataFrame objects + def _ensure_df(df): + return df if isinstance(df, pd.DataFrame) else pd.DataFrame(df) + + active = _ensure_df(active) + awaiting = _ensure_df(awaiting) + enforced = _ensure_df(enforced) + revoked = _ensure_df(revoked) + for df, status in [ + (active, "active"), + (awaiting, "awaiting"), + (enforced, "enforced"), + (revoked, "revoked"), + ]: + if "status" not in df.columns: + df["status"] = status + combined = pd.concat([active, awaiting, enforced, revoked], ignore_index=True) + if "otpid" in combined.columns: + combined = combined.sort_values(by="otpid", ascending=False) + self._sessions_df = combined + # Populate DataTable + self.sessions_table.clear() + # Ensure columns exist in DF and when missing add empty column + expected_cols = ["otpid", "hostname", "status", "purpose", "granted"] + for col in expected_cols: + if col not in combined.columns: + combined[col] = "" + self.sessions_table.add_columns(*expected_cols) + # Add rows + for _, row in combined[expected_cols].iterrows(): + # Convert values to str for safe insertion + vals = ["" if pd.isna(v) else v for v in row.to_list()] + self.sessions_table.add_row(*[str(v) for v in vals]) + logger.info("Loaded %d OTP sessions.", len(combined)) + + async def _fetch_activities_for_otpid(self, api, otpid, hostname=None) -> None: + """ + Fetch activities DataFrame for a given otpid and populate activities_table. + """ + try: + result = api.otp_get_activities(otpid) + result_df = ( + result if isinstance(result, pd.DataFrame) else pd.DataFrame(result) + ) + except Exception as exc: + logger.exception("Failed to fetch activities for otpid %s: %s", otpid, exc) + result_df = pd.DataFrame() + # Attach hostname if provided + if hostname is not None: + result_df["hostname"] = hostname + if result_df.empty: + logger.info("No activities found for otpid %s (host: %s)", otpid, hostname) + self._activities_df = pd.DataFrame() + self.activities_table.clear() + return + # Store and render + self._activities_df = result_df.copy() + # Rebuild activities_table columns from result_df + self.activities_table.clear() + # Ensure stable column order + for col in result_df.columns: + self.activities_table.add_column(col) + # Add rows + for _, arow in result_df.iterrows(): + values = ["" if pd.isna(v) else v for v in arow.to_list()] + self.activities_table.add_row(*[str(v) for v in values]) + logger.info( + "Loaded %d activity rows for otpid %s (host: %s)", + len(result_df), + otpid, + hostname, + ) + + async def export_activities(self) -> None: + """ + Export currently-loaded activities DataFrame to CSV. + Can be called directly (programmatically) or from the button handler. + """ + if self._activities_df is None or self._activities_df.empty: + logger.info("No activities loaded to export.") + # On-screen short message + await self.post_message(Static("No activities to export.")) + return + working_dir = _load_working_dir() + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"otp_activities_{self._selected_session_otpid}_{timestamp}.csv" + file_path = os.path.join(working_dir, filename) + try: + self._activities_df.to_csv(file_path, index=False) + logger.info("Exported activities to %s", file_path) + await self.post_message(Static(f"βœ… Exported activities to: {file_path}")) + except Exception as exc: + logger.exception("Failed to export activities to %s: %s", file_path, exc) + await self.post_message(Static("Failed to export activities; check logs.")) + + +class ActivityDetailWidget(Static): + """ + Interactive widget for Activity Detail screen. + Shows the provided DataFrame in a DataTable and offers Export + Back buttons. + """ + + DEFAULT_CSS = """ + ActivityDetailWidget { + height: 1fr; + layout: vertical; + } + #detail_table_container { + height: 85%; + padding: 1 1; + } + #detail_buttons { + height: 15%; + padding: 1 1; + content-align: center middle; + } + """ + + def __init__(self, activities_df: pd.DataFrame, otpid=None, hostname=None) -> None: + super().__init__() + self.activities_df = ( + activities_df.copy() + if isinstance(activities_df, pd.DataFrame) + else pd.DataFrame(activities_df) + ) + self.otpid = otpid + self.hostname = hostname + + def compose(self) -> ComposeResult: + yield Static( + f"Activity Detail (otpid={self.otpid} host={self.hostname})", + classes="panel-title", + ) + # Table container + with Vertical(id="detail_table_container"): + self.detail_table = DataTable(id="detail_table") + yield self.detail_table + # Buttons at bottom + with Horizontal(id="detail_buttons"): + self.detail_back_btn = Button("Back", id="detail_back_btn") + self.detail_export_btn = Button("Export (CSV)", id="detail_export_btn") + # Make them stretch equally + self.detail_back_btn.styles.width = "50%" + self.detail_export_btn.styles.width = "50%" + yield self.detail_back_btn + yield self.detail_export_btn + + async def on_mount(self) -> None: + # Populate table from activities_df + self.detail_table.clear() + if self.activities_df is None or self.activities_df.empty: + logger.info("ActivityDetailWidget mounted with empty dataframe.") + return + # Add columns + for col in self.activities_df.columns: + self.detail_table.add_column(col) + # Add rows + for _, row in self.activities_df.iterrows(): + vals = ["" if pd.isna(v) else v for v in row.to_list()] + self.detail_table.add_row(*[str(v) for v in vals]) + # Allow sorting / cursor + self.detail_table.cursor_type = "row" + + async def on_button_pressed(self, event) -> None: + btn = getattr(event, "button", None) or getattr(event, "sender", None) + btn_id = getattr(btn, "id", None) or getattr(event, "button_id", None) + # Back button in ActivityDetailWidget + + if btn is self.detail_back_btn or btn_id == "detail_back_btn": + # Pop screens until only the main menu remains + while len(self.app.screen_stack) > 2: + self.app.pop_screen() + event.stop() + return + # Export button + if btn is self.detail_export_btn or btn_id == "detail_export_btn": + await self._export_detail_activities() + return + + async def _export_detail_activities(self) -> None: + + if self.activities_df is None or self.activities_df.empty: + logger.info("No activities to export.") + notification = Static("❌ No activities to export.", classes="notification") + self.mount(notification) + return + try: + working_dir = load_env("WORKING_DIR") or os.getcwd() + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"otp_activities_detail_{timestamp}.csv" + file_path = os.path.join(working_dir, filename) + self.activities_df.to_csv(file_path, index=False) + logger.info("Exported detail activities to %s", file_path) + # Show success notification + notification = Static( + f"βœ… Exported activities to: {filename}", classes="notification" + ) + self.mount(notification) + except Exception as exc: + logger.exception("Failed to export detail activities: %s", exc) + notification = Static( + "❌ Failed to export activities; check logs.", classes="notification" + ) + self.mount(notification) + + +class ActivityDetailScreen(Screen): + """ + Screen that wraps ActivityDetailWidget. Expects a DataFrame passed on init. + """ + + BINDINGS = [Binding("b", "back", "Back"), Binding("e", "export", "Export")] + + def __init__(self, activities_df: pd.DataFrame, otpid=None, hostname=None) -> None: + super().__init__() + self._activities_df = ( + activities_df.copy() + if isinstance(activities_df, pd.DataFrame) + else pd.DataFrame(activities_df) + ) + self._otpid = otpid + self._hostname = hostname + + def compose(self) -> ComposeResult: + self.widget = ActivityDetailWidget( + self._activities_df, otpid=self._otpid, hostname=self._hostname + ) + yield Header(show_clock=True) + yield self.widget + yield Footer() + + async def action_back(self) -> None: + try: + await self.app.pop_screen() + except Exception: + logger.debug("ActivityDetailScreen.action_back pop_screen failed.") + + async def action_export(self) -> None: + # Delegate to widget export helper + if hasattr(self, "widget") and self.widget is not None: + await self.widget._export_detail_activities() + + +class OTPActivitiesScreen(Screen): + """ + A Screen intended to be pushed into an existing Textual App. + Usage: + app.push_screen(OTPActivitiesScreen()) + or create this screen and call `await screen.load()` inside your app lifecycle. + The screen expects `self.app.api` to exist and be an AirlockAPIWrapper instance. + """ + + BINDINGS = [ + Binding("r", "refresh_sessions", "Refresh Sessions"), + Binding("e", "export_activities", "Export activities"), + Binding("q", "quit", "Quit"), + ] + + def compose(self) -> ComposeResult: + yield Header() + self.widget = OTPActivitiesWidget() + yield self.widget + yield Footer() + + async def on_show(self) -> None: + """Restore focus to the left sessions table when the screen becomes visible.""" + if hasattr(self, "widget") and hasattr(self.widget, "sessions_table"): + self.widget.sessions_table.focus() + + async def on_mount(self) -> None: + # Try to load sessions immediately + api = getattr(self.app, "api", None) + if api is None: + logger.warning("OTPActivitiesScreen mounted but no self.app.api found.") + else: + await self.widget.load_sessions_from_api(api) + + # Simple actions bound to keys + async def action_refresh_sessions(self) -> None: + api = getattr(self.app, "api", None) + if api is None: + logger.error("No API on app; cannot refresh sessions.") + return + logger.info("Refreshing OTP sessions via API.") + await self.widget.load_sessions_from_api(api) + + async def action_quit(self) -> None: + # Pop the screen or exit app + await self.app.pop_screen() + + # If you want an explicit method to fetch activities for a particular otpid from outside: + async def fetch_activities_for_otpid(self, otpid, hostname=None) -> None: + api = getattr(self.app, "api", None) + if api is None: + logger.error("No API on app; cannot fetch activities.") + return + await self.widget._fetch_activities_for_otpid(api, otpid, hostname=hostname) diff --git a/screens/otpworkflowscreen.py b/screens/otpworkflowscreen.py index f3710b3..519ff6b 100644 --- a/screens/otpworkflowscreen.py +++ b/screens/otpworkflowscreen.py @@ -1,6 +1,6 @@ # otp_workflow_screen.py -from typing import List +from typing import List, Optional from textual.app import ComposeResult from textual.screen import Screen @@ -12,7 +12,7 @@ from widgets.OTP_generate import OTPGenerator class OTPWorkflowScreen(Screen): """Screen that handles the OTP generation workflow without agent selection.""" - def __init__(self, selected_agents: List[Agent]): + def __init__(self, selected_agents: Optional[List[Agent]]): super().__init__() self.selected_agents = selected_agents diff --git a/widgets/amber_terminal_theme.py b/themes/amber_terminal_theme.py similarity index 100% rename from widgets/amber_terminal_theme.py rename to themes/amber_terminal_theme.py diff --git a/widgets/retro_terminal_theme.py b/themes/retro_terminal_theme.py similarity index 100% rename from widgets/retro_terminal_theme.py rename to themes/retro_terminal_theme.py diff --git a/utils/tui.py b/utils/tui.py index 8cadf33..9e55261 100644 --- a/utils/tui.py +++ b/utils/tui.py @@ -1,11 +1,13 @@ import logging import os import sys +from typing import Optional import dotenv from dotenv import set_key from textual.app import App, ComposeResult from textual.containers import Vertical +from textual.message import Message from textual.reactive import reactive from textual.screen import Screen from textual.widgets import ( @@ -18,25 +20,26 @@ from textual.widgets import ( Tabs, ) -from flows.otp import otp_activities_by_agent, otp_revoke +from flows.otp import otp_revoke from flows.prepPolicy import menu_policy_enforce from flows.quietAgent import findQuietAgents from models.agent import Agent from models.policy import Policy from screens.moveagentworkflowscreen import MoveAgentWorkflowScreen +from screens.otpactivityscreen import OTPActivitiesScreen from screens.otpworkflowscreen import OTPWorkflowScreen from services.API import AirlockAPIWrapper from services.policyhandler import confirmUpdateAfromE +from themes.amber_terminal_theme import get_amber_terminal_theme +from themes.retro_terminal_theme import get_retro_terminal_theme from utils.configmanager import load_env from utils.setup import get_base_directory, load_user_config from utils.utils import open_directory from widgets.agentmoveoperations import AgentMoveOperations -from widgets.amber_terminal_theme import get_amber_terminal_theme from widgets.multiagentselector import MultiAgentSelector from widgets.OTP_generate import OTPGenerator from widgets.policytreewidget import PolicyTreeWidget from widgets.resultsdisplay import ResultsDisplay -from widgets.retro_terminal_theme import get_retro_terminal_theme from widgets.themeselector import ThemeSelector dotenv.load_dotenv() @@ -102,6 +105,7 @@ def _persist_user_theme(theme_name: str) -> None: # 1) SCREEN # --------------------------------------------------------------------------- class MainMenuScreen(Screen): + api: AirlockAPIWrapper current_tab = reactive("") BUTTON_DEFS = { @@ -120,9 +124,8 @@ class MainMenuScreen(Screen): ], } - def __init__(self, api: AirlockAPIWrapper) -> None: + def __init__(self) -> None: super().__init__() - self.api = api self.extras = load_env("EXTRAS") wd = load_env("WORKING_DIR") or os.getcwd() if not os.path.isdir(wd): @@ -156,6 +159,7 @@ class MainMenuScreen(Screen): yield Footer() def on_mount(self) -> None: + api = self.app.api self.switch_tab("agent_actions") # focus helpers @@ -321,34 +325,55 @@ class MainMenuScreen(Screen): self.app.push_screen(MoveAgentWorkflowScreen(self.app.devices)) event.stop() return # Don't exit the app + case "otp_generate_button": # NEW: Push OTP workflow screen instead of legacy function self.app.push_screen(OTPWorkflowScreen(self.app.devices)) event.stop() return # Don't exit the app + case "find_quiet_button": _PENDING_JOB = ("legacy", findQuietAgents, (self.app.api,), {}) + case "otp_activities_button": - _PENDING_JOB = ("legacy", otp_activities_by_agent, (self.app.api,), {}) + # === FIXED: push the Textual OTPActivitiesScreen and return immediately === + # This must return so we don't fall through to the code that exits the app. + self.app.push_screen(OTPActivitiesScreen()) + event.stop() + return + case "otp_revoke_button": _PENDING_JOB = ("legacy", otp_revoke, (self.app.api,), {}) + case "policy_prep_button": _PENDING_JOB = ("legacy", menu_policy_enforce, (self.app.api,), {}) + case "policy_audit_update_button": _PENDING_JOB = ("legacy", confirmUpdateAfromE, (self.app.api,), {}) + case _: self.app.bell() logger.warning("Unknown button pressed: %s", button_id) return + # Only exit the UI loop when we explicitly queued a legacy job. + # The original flow used `self.app.exit()` after setting _PENDING_JOB so + # the outer loop could run legacy code. Keep that behavior only for legacy jobs. logger.debug("Set _PENDING_JOB = %r", _PENDING_JOB) - self.app.exit() + if _PENDING_JOB and _PENDING_JOB[0] == "legacy": + # let the main loop pick up the legacy job + self.app.exit() # --------------------------------------------------------------------------- # 2) APP # --------------------------------------------------------------------------- -class Loxide(App): +class Loxide(App[Message]): + api: AirlockAPIWrapper + working_dir: str + policies: Optional[list[Policy]] + devices: Optional[list[Agent]] + CSS = """ #logo { width: 100%; @@ -398,7 +423,7 @@ class Loxide(App): self.register_theme(get_retro_terminal_theme()) self.register_theme(get_amber_terminal_theme()) self.theme = self._textual_theme - self.push_screen(MainMenuScreen(api)) + self.push_screen(MainMenuScreen()) def action_quit(self) -> None: global _PENDING_JOB @@ -406,12 +431,13 @@ class Loxide(App): self.exit() def action_open_dir(self) -> None: - # Refresh data before proceeding - self.refresh_data() - screen = self.screen_stack[-1] - if isinstance(screen, MainMenuScreen): - if screen.current_tab != "dir": - screen.switch_tab("dir") + """Open the working directory in the OS file manager (footer binding).""" + path_to_open = self.working_dir or os.getcwd() + try: + open_directory(path_to_open) + except Exception as exc: + logger.error("Failed to open directory %s: %s", path_to_open, exc) + self.bell() # optional feedback # --------------------------------------------------------------------------- diff --git a/widgets/OTP_generate.py b/widgets/OTP_generate.py index 221394b..4ca7977 100644 --- a/widgets/OTP_generate.py +++ b/widgets/OTP_generate.py @@ -1,5 +1,5 @@ import logging -from typing import List +from typing import List, Optional from textual.containers import Horizontal, Vertical from textual.css.query import NoMatches @@ -31,7 +31,11 @@ class OTPGenerator(Widget): class OTPInfo(Message): def __init__( - self, devices: List[Agent], requestor: str, reasoning: str, duration: int + self, + devices: Optional[List[Agent]], + requestor: str, + reasoning: str, + duration: int, ): super().__init__() self.devices = devices @@ -243,7 +247,7 @@ class OTPGenerator(Widget): self.otp_generated = True # Access API from the app - this is the key change! - api = self.app.api + api = self.app.api # type: ignore output_lines = [ "Requested OTP Codes:", diff --git a/widgets/multiagentselector.py b/widgets/multiagentselector.py index f4ea371..afe8e14 100644 --- a/widgets/multiagentselector.py +++ b/widgets/multiagentselector.py @@ -1,6 +1,6 @@ import difflib import re -from typing import List +from typing import List, Optional from textual.containers import Horizontal, Vertical from textual.css.query import NoMatches @@ -25,7 +25,7 @@ class MultiAgentSelector(Widget): super().__init__() self.selected_agents = selected_agents - def __init__(self, all_agents: List[Agent]): + def __init__(self, all_agents: Optional[List[Agent]]): super().__init__() self.all_agents = all_agents self._match_type = "exact" diff --git a/widgets/policytreewidget.py b/widgets/policytreewidget.py index f611d5d..5e01fdf 100644 --- a/widgets/policytreewidget.py +++ b/widgets/policytreewidget.py @@ -4,7 +4,7 @@ import logging from rich.text import Text from textual.containers import Horizontal, Vertical from textual.widget import Widget -from textual.widgets import Input, OptionList, Static, Tree +from textual.widgets import Input, OptionList, Static, Switch, Tree from textual.widgets.option_list import Option logger = logging.getLogger(__name__) @@ -19,29 +19,49 @@ class PolicyTreeWidget(Widget): self.devices = devices self.last_highlighted_node = None self.leaf_counts = defaultdict(int) + self.match_type = "Count" # Default to sorting by count def compose(self): + # Create the switch and its label + switch = Switch(value=False, id="match_switch") + switch.styles.margin = (0, 0, 0, 0) # top, right, bottom, left + switch.styles.padding = (0, 0, 0, 0) + + switch_label = Static("Sort: Count", id="match_switch_label") + switch_label.styles.margin = (1, 0, 0, 0) + switch_label.styles.padding = (0, 0, 0, 0) + + # Create the tree policy_tree = Tree("", id="policy_tree") # Label set in on_mount policy_tree.styles.width = "2fr" policy_tree.styles.height = "100%" + # Create the search box and details pane label = Static("Device Search:") search_box = Input( placeholder="Search policies or devices...", id="tree_search" ) details_pane = Static("", id="details_pane") + # Layout the UI with Horizontal(): yield policy_tree with Vertical() as right_pane: right_pane.styles.width = "3fr" + # Use a Horizontal container for the switch and label + with Horizontal() as switch_container: + switch_container.styles.height = 3 + switch_container.styles.margin = (0, 0, 0, 1) + switch_container.styles.padding = (0, 0, 0, 0) + yield switch + yield switch_label + # Add the search box and details pane yield label yield search_box yield details_pane def on_mount(self) -> None: self._precompute_leaf_counts() - # Update root label with total leaf count total_leaves = sum( self.leaf_counts.get(policy.groupid, 0) @@ -50,8 +70,9 @@ class PolicyTreeWidget(Widget): ) policy_tree = self.query_one("#policy_tree", Tree) policy_tree.root.set_label(f"Agents in Policies: ({total_leaves})") - self._build_tree() + # Expand the root node + policy_tree.root.expand() def _precompute_leaf_counts(self): """Precompute leaf counts for each policy group.""" @@ -76,15 +97,21 @@ class PolicyTreeWidget(Widget): def _build_tree(self): policy_tree = self.query_one("#policy_tree", Tree) + policy_tree.clear() # Clear existing nodes node_map = {} # Sort top-level policies top_policies = [ p for p in self.policies if p.parent == "global-policy-settings" ] - top_policies.sort( - key=lambda p: self.leaf_counts.get(p.groupid, 0), reverse=True - ) + + # Sort by count (default) or alphabetically + if getattr(self, "match_type", "Count") == "Count": + top_policies.sort( + key=lambda p: self.leaf_counts.get(p.groupid, 0), reverse=True + ) + else: # Alphabetical + top_policies.sort(key=lambda p: p.name.lower()) for policy in top_policies: label = f"{policy.name} ({self.leaf_counts.get(policy.groupid, 0)})" @@ -98,9 +125,13 @@ class PolicyTreeWidget(Widget): children_by_parent[policy.parent].append(policy) for parent_id, children in children_by_parent.items(): - children.sort( - key=lambda p: self.leaf_counts.get(p.groupid, 0), reverse=True - ) + if getattr(self, "match_type", "Count") == "Count": + children.sort( + key=lambda p: self.leaf_counts.get(p.groupid, 0), reverse=True + ) + else: # Alphabetical + children.sort(key=lambda p: p.name.lower()) + parent_node = node_map.get(parent_id) if parent_node: for policy in children: @@ -108,12 +139,17 @@ class PolicyTreeWidget(Widget): node = parent_node.add(label=label, data=policy) node_map[policy.groupid] = node - # Add devices (leaf nodes) + # Add devices (leaf nodes) - always sort alphabetically + devices_by_group = defaultdict(list) for device in self.devices: - group_id = device.groupid + devices_by_group[device.groupid].append(device) + + for group_id, devices in devices_by_group.items(): + devices.sort(key=lambda d: d.hostname.lower()) # Always sort alphabetically parent_node = node_map.get(group_id) if parent_node: - parent_node.add(label=device.hostname, data=device) + for device in devices: + parent_node.add(label=device.hostname, data=device) def _collect_tree_nodes(self, node, all_nodes): all_nodes.append(node) @@ -157,6 +193,13 @@ class PolicyTreeWidget(Widget): message.stop() + def on_switch_changed(self, event: Switch.Changed): + self.match_type = "Alpha" if event.value else "Count" + self.query_one("#match_switch_label", Static).update( + f"Sort: {self.match_type.capitalize()}" + ) + self._build_tree() + def on_input_submitted(self, message: Input.Submitted) -> None: self._remove_match_selector() From 0ebb42dcbde51626cc1f4f9d7175c6eef126f92a Mon Sep 17 00:00:00 2001 From: Zarithas Date: Thu, 13 Nov 2025 12:10:06 -0500 Subject: [PATCH 14/29] UI Tweaks + Config Addition of TELEMETRY and TELEM_URL fields --- default_system_config.json | 4 +++- utils/configmanager.py | 2 ++ utils/setup.py | 6 +++++- utils/tui.py | 6 +++--- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/default_system_config.json b/default_system_config.json index b09f901..decfd79 100644 --- a/default_system_config.json +++ b/default_system_config.json @@ -1,5 +1,5 @@ { - "APPNAME": "AirlockTools", + "APPNAME": "Loxide", "URL": "https://server:3129", "LOG_LEVEL": "INFO", "BAD_PATH_PARTS": ["users","wwwroot","windows\\temp","windows\\task","windows\\system32","startup", "windows\\fonts","Recycle.Bin","AppData","programdata", "Solarwinds","kaseya"], @@ -8,6 +8,8 @@ "PATH_EXCLUSION_CONST": 4, "MIN_FILES_FOR_PATH": 4, "VT_THREAT_TOLERANCE": 4, + "TELEMETRY": "FALSE", + "TELEM_URL": "", "POLICY_MAP_ENF_AUD": { } diff --git a/utils/configmanager.py b/utils/configmanager.py index b099fa3..7b5aea5 100644 --- a/utils/configmanager.py +++ b/utils/configmanager.py @@ -24,6 +24,8 @@ T = TypeVar("T") logger = logging.getLogger(__name__) PROTECTED_KEYS = [ + "URL", + "TELEM_URL", "APPNAME", "LOG_LEVEL", "PATH_EXCLUSION_CONST", diff --git a/utils/setup.py b/utils/setup.py index 4277389..77bc5a0 100644 --- a/utils/setup.py +++ b/utils/setup.py @@ -122,7 +122,11 @@ def load_system_config() -> dict: def load_user_config(config_dir: Path) -> dict: user_config_path = config_dir / "user_config.json" if not user_config_path.exists(): - default_user_config = {"URL": "", "LOG_LEVEL": "INFO"} + default_user_config = { + "TELEMETRY": "FALSE", + "TEXTUAL_THEME": "gruvbox", + "EXTRAS": "NOTTODAY", + } with open(user_config_path, "w") as f: json.dump(default_user_config, f, indent=4) logging.debug(f"Created user config at {user_config_path}") diff --git a/utils/tui.py b/utils/tui.py index 9e55261..9f32133 100644 --- a/utils/tui.py +++ b/utils/tui.py @@ -114,12 +114,12 @@ class MainMenuScreen(Screen): "πŸ–₯️ - Find, Move, or Generate OTP for Agents", "move_agent_workflow_button", ), + ("πŸ“Š - OTP Activities By Agent", "otp_activities_button"), ("πŸ”‡ - Find Quiet Hosts", "find_quiet_button"), ], "policy": [ ("πŸ”’ - Prepare Policy For Enforcement", "policy_prep_button"), ("πŸ”„ - Update Audit Policies", "policy_audit_update_button"), - ("πŸ“Š - OTP Activities By Agent", "otp_activities_button"), ("❌ - Revoke OTPs", "otp_revoke_button"), ], } @@ -383,7 +383,7 @@ class Loxide(App[Message]): """ BINDINGS = [ ("q", "quit", "Quit"), - ("d", "open_dir", "Open Directory"), + ("f", "open_fe", "Launch Explorer"), ] def __init__(self, api: AirlockAPIWrapper): @@ -430,7 +430,7 @@ class Loxide(App[Message]): _PENDING_JOB = None self.exit() - def action_open_dir(self) -> None: + def action_open_fe(self) -> None: """Open the working directory in the OS file manager (footer binding).""" path_to_open = self.working_dir or os.getcwd() try: From bf5c7d156b50824fdd644f85bd20157e47af5189 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Fri, 14 Nov 2025 13:41:04 -0500 Subject: [PATCH 15/29] Added in Telemetry Next Build will have opt-in/opt-out capabilities --- airlock_libs/Cargo.lock | 1247 ++++++++++++++++++++++++++++------ airlock_libs/Cargo.toml | 10 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/services.rs | 237 ++++--- 4 files changed, 1219 insertions(+), 277 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index a3d9b52..5378795 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -15,19 +15,36 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "airlock_libs" -version = "2.0.0" +version = "3.0.0" dependencies = [ "chrono", "indicatif", "mongodb", + "opentelemetry 0.18.0", + "opentelemetry-otlp", + "opentelemetry-proto", + "opentelemetry-semantic-conventions", "pyo3", "reqwest", "serde", "serde-pyobject", "serde_json", "tokio", + "tonic", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", ] [[package]] @@ -39,6 +56,34 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -47,7 +92,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -62,12 +107,63 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper 0.1.2", + "tower 0.4.13", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -144,9 +240,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.41" +version = "1.2.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" dependencies = [ "find-msvc-tools", "shlex", @@ -236,6 +332,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crunchy" version = "0.2.4" @@ -244,9 +355,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -273,7 +384,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.110", ] [[package]] @@ -284,7 +395,20 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.110", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", ] [[package]] @@ -311,7 +435,7 @@ checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -322,7 +446,7 @@ checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -335,7 +459,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.110", ] [[package]] @@ -357,7 +481,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -366,6 +490,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -387,10 +517,10 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -421,6 +551,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fnv" version = "1.0.7" @@ -457,6 +593,20 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -464,6 +614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -497,7 +648,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -518,9 +669,11 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", + "futures-sink", "futures-task", "memchr", "pin-project-lite", @@ -530,9 +683,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -565,6 +718,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.12.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.12" @@ -576,7 +748,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.3.1", "indexmap 2.12.0", "slab", "tokio", @@ -590,12 +762,24 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -625,7 +809,7 @@ dependencies = [ "ipnet", "once_cell", "rand 0.8.5", - "thiserror", + "thiserror 1.0.69", "tinyvec", "tokio", "tracing", @@ -648,7 +832,7 @@ dependencies = [ "rand 0.8.5", "resolv-conf", "smallvec", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -662,6 +846,26 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.3.1" @@ -673,6 +877,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -680,7 +895,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.3.1", ] [[package]] @@ -691,8 +906,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "pin-project-lite", ] @@ -703,18 +918,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "hyper" -version = "1.7.0" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.12", + "http 1.3.1", + "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", @@ -730,16 +975,28 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", - "hyper", + "http 1.3.1", + "hyper 1.8.0", "hyper-util", - "rustls", + "rustls 0.23.35", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper 0.14.32", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -748,7 +1005,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.8.0", "hyper-util", "native-tls", "tokio", @@ -767,9 +1024,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", - "hyper", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.8.0", "ipnet", "libc", "percent-encoding", @@ -808,9 +1065,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", @@ -821,9 +1078,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -834,11 +1091,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -849,42 +1105,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -944,9 +1196,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.2" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade6dfcba0dfb62ad59e59e7241ec8912af34fd29e0e743e3db992bd278e8b65" +checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" dependencies = [ "console", "portable-atomic", @@ -957,9 +1209,12 @@ dependencies = [ [[package]] name = "indoc" -version = "2.0.6" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] [[package]] name = "ipconfig" @@ -981,14 +1236,23 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" dependencies = [ "memchr", "serde", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -997,14 +1261,20 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" dependencies = [ "once_cell", "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.177" @@ -1017,6 +1287,12 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1025,9 +1301,9 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lock_api" @@ -1062,7 +1338,7 @@ dependencies = [ "macro_magic_core", "macro_magic_macros", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -1076,7 +1352,7 @@ dependencies = [ "macro_magic_core_macros", "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -1087,7 +1363,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -1098,9 +1374,15 @@ checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" dependencies = [ "macro_magic_core", "quote", - "syn", + "syn 2.0.110", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "md-5" version = "0.10.6" @@ -1191,7 +1473,7 @@ dependencies = [ "percent-encoding", "rand 0.8.5", "rustc_version_runtime", - "rustls", + "rustls 0.23.35", "rustversion", "serde", "serde_bytes", @@ -1202,9 +1484,9 @@ dependencies = [ "stringprep", "strsim", "take_mut", - "thiserror", + "thiserror 1.0.69", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "typed-builder", "uuid", @@ -1220,9 +1502,15 @@ dependencies = [ "macro_magic", "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + [[package]] name = "native-tls" version = "0.2.14" @@ -1240,6 +1528,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1263,9 +1560,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openssl" -version = "0.10.74" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -1284,7 +1581,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -1295,9 +1592,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.110" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", @@ -1305,6 +1602,123 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "opentelemetry" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d6c3d7288a106c0a363e4b0e8d308058d56902adefb16f4936f417ffef086e" +dependencies = [ + "opentelemetry_api", + "opentelemetry_sdk 0.18.0", +] + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.17", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c928609d087790fc936a1067bdc310ae702bdf3b090c3f281b713622c8bbde" +dependencies = [ + "async-trait", + "futures", + "futures-util", + "http 0.2.12", + "opentelemetry 0.18.0", + "opentelemetry-proto", + "prost", + "thiserror 1.0.69", + "tokio", + "tonic", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d61a2f56df5574508dd86aaca016c917489e589ece4141df1b5e349af8d66c28" +dependencies = [ + "futures", + "futures-util", + "opentelemetry 0.18.0", + "prost", + "tonic", + "tonic-build", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b02e0230abb0ab6636d18e2ba8fa02903ea63772281340ccac18e0af3ec9eeb" +dependencies = [ + "opentelemetry 0.18.0", +] + +[[package]] +name = "opentelemetry_api" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c24f96e21e7acc813c7a8394ee94978929db2bcc46cf6b5014fc612bf7760c22" +dependencies = [ + "fnv", + "futures-channel", + "futures-util", + "indexmap 1.9.3", + "js-sys", + "once_cell", + "pin-project-lite", + "thiserror 1.0.69", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca41c4933371b61c2a2f214bf16931499af4ec90543604ec828f7a625c09113" +dependencies = [ + "async-trait", + "crossbeam-channel", + "dashmap", + "fnv", + "futures-channel", + "futures-executor", + "futures-util", + "once_cell", + "opentelemetry_api", + "percent-encoding", + "rand 0.8.5", + "thiserror 1.0.69", + "tokio", + "tokio-stream", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry 0.31.0", + "percent-encoding", + "rand 0.9.2", + "thiserror 2.0.17", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1343,6 +1757,36 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.12.0", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1369,9 +1813,9 @@ checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "potential_utf" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] @@ -1392,19 +1836,83 @@ dependencies = [ ] [[package]] -name = "proc-macro2" -version = "1.0.101" +name = "prettyplease" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "6c8646e95016a7a6c4adea95bafa8a16baab64b583356217f2c85db4a39d9a86" +dependencies = [ + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] -name = "pyo3" -version = "0.27.0" +name = "prost" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa8e48c12afdeb26aa4be4e5c49fb5e11c3efa0878db783a960eea2b9ac6dd19" +checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270" +dependencies = [ + "bytes", + "heck 0.4.1", + "itertools", + "lazy_static", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 1.0.109", + "tempfile", + "which", +] + +[[package]] +name = "prost-derive" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-types" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13" +dependencies = [ + "prost", +] + +[[package]] +name = "pyo3" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a6df7eab65fc7bee654a421404947e10a0f7085b6951bf2ea395f4659fb0cf" dependencies = [ "indoc", "libc", @@ -1419,9 +1927,9 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc1989dbf2b60852e0782c7487ebf0b4c7f43161ffe820849b56cf05f945cee1" +checksum = "f77d387774f6f6eec64a004eac0ed525aab7fa1966d94b42f743797b3e395afb" dependencies = [ "python3-dll-a", "target-lexicon", @@ -1429,9 +1937,9 @@ dependencies = [ [[package]] name = "pyo3-ffi" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c808286da7500385148930152e54fb6883452033085bf1f857d85d4e82ca905c" +checksum = "2dd13844a4242793e02df3e2ec093f540d948299a6a77ea9ce7afd8623f542be" dependencies = [ "libc", "pyo3-build-config", @@ -1439,27 +1947,27 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0543c16be0d86cf0dbf2e2b636ece9fd38f20406bb43c255e0bc368095f92" +checksum = "eaf8f9f1108270b90d3676b8679586385430e5c0bb78bb5f043f95499c821a71" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 2.0.110", ] [[package]] name = "pyo3-macros-backend" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a00da2ce064dcd582448ea24a5a26fa9527e0483103019b741ebcbe632dcd29" +checksum = "70a3b2274450ba5288bc9b8c1b69ff569d1d61189d4bff38f8d22e03d17f932b" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "pyo3-build-config", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -1473,9 +1981,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -1577,9 +2085,38 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "reqwest" version = "0.12.24" @@ -1590,11 +2127,11 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.12", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.0", "hyper-rustls", "hyper-tls", "hyper-util", @@ -1608,10 +2145,10 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -1626,6 +2163,21 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted 0.7.1", + "web-sys", + "winapi", +] + [[package]] name = "ring" version = "0.17.14" @@ -1636,7 +2188,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -1659,6 +2211,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.2" @@ -1668,19 +2233,31 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.33" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "751e04a496ca00bb97a5e043158d23d66b5aabf2e1d5aa2a0aaebb1aafe6f82c" +checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" +dependencies = [ + "log", + "ring 0.16.20", + "sct", + "webpki", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "log", "once_cell", - "ring", + "ring 0.17.14", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1688,23 +2265,44 @@ dependencies = [ ] [[package]] -name = "rustls-pki-types" -version = "1.12.0" +name = "rustls-native-certs" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.7" +version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ - "ring", + "ring 0.17.14", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -1742,9 +2340,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.0.5" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1317c3bf3e7df961da95b0a56a172a02abead31276215a0497241a7624b487ce" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" dependencies = [ "dyn-clone", "ref-cast", @@ -1758,6 +2356,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -1835,7 +2443,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -1876,7 +2484,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.12.0", "schemars 0.9.0", - "schemars 1.0.5", + "schemars 1.1.0", "serde_core", "serde_json", "serde_with_macros", @@ -1892,7 +2500,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -1917,6 +2525,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1964,6 +2581,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1995,15 +2618,32 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.107" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "syn" +version = "2.0.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2021,7 +2661,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -2072,7 +2712,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.2", "windows-sys 0.61.2", ] @@ -2082,7 +2722,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", ] [[package]] @@ -2093,7 +2742,27 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", ] [[package]] @@ -2138,9 +2807,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -2178,6 +2847,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-macros" version = "2.6.0" @@ -2186,7 +2865,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -2199,21 +2878,43 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +dependencies = [ + "rustls 0.20.9", + "tokio", + "webpki", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.35", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", "tokio", ] [[package]] name = "tokio-util" -version = "0.7.16" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", @@ -2223,6 +2924,74 @@ dependencies = [ "tokio", ] +[[package]] +name = "tonic" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f219fad3b929bef19b1f86fbc0358d35daed8f2cac972037ac0dc10bbb8d5fb" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.13.1", + "bytes", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "prost-derive", + "rustls-native-certs", + "rustls-pemfile", + "tokio", + "tokio-rustls 0.23.4", + "tokio-stream", + "tokio-util", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", + "tracing-futures", +] + +[[package]] +name = "tonic-build" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf5e9b9c0f7e0a7c027dcfaba7b2c60816c7049171f679d99ee2ff65d0de8c4" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -2232,7 +3001,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -2247,11 +3016,11 @@ dependencies = [ "bitflags 2.10.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", ] @@ -2287,7 +3056,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -2297,6 +3066,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6e5658463dd88089aba75c7791e1d3120633b1bfde22478b28f625a9bb1b8e" +dependencies = [ + "js-sys", + "opentelemetry 0.31.0", + "opentelemetry_sdk 0.31.0", + "rustversion", + "smallvec", + "thiserror 2.0.17", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -2322,7 +3146,7 @@ checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -2339,9 +3163,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-normalization" @@ -2376,6 +3200,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -2412,6 +3242,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -2450,9 +3286,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ "cfg-if", "once_cell", @@ -2461,25 +3297,11 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" dependencies = [ "cfg-if", "js-sys", @@ -2490,9 +3312,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2500,31 +3322,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn", - "wasm-bindgen-backend", + "syn 2.0.110", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" dependencies = [ "js-sys", "wasm-bindgen", @@ -2540,6 +3362,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2558,12 +3390,46 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "widestring" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -2585,7 +3451,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -2596,7 +3462,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -2898,9 +3764,9 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wyz" @@ -2913,11 +3779,10 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -2925,13 +3790,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", "synstructure", ] @@ -2952,7 +3817,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -2972,7 +3837,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", "synstructure", ] @@ -2984,9 +3849,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -2995,9 +3860,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -3006,11 +3871,11 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 3a853d9..782fd33 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "2.0.0" +version = "3.0.0" edition = "2024" [lib] @@ -10,12 +10,20 @@ crate-type = ["cdylib"] chrono = "0.4.42" indicatif = "0.18.2" mongodb = "3.3.0" +opentelemetry = { version = "0.18.0", features = ["rt-tokio", "metrics", "trace"] } +opentelemetry-otlp = { version = "0.11.0", features = ["trace", "metrics"] } +opentelemetry-semantic-conventions = { version = "0.10.0" } +opentelemetry-proto = { version = "0.1.0"} pyo3 = { version = "0.27.0", features = ["extension-module", "generate-import-lib"] } reqwest = { version = "0.12.24", features = ["json", "native-tls"] } serde = "1.0.228" serde-pyobject = "0.8.0" serde_json = "1.0.145" tokio = { version = "1.48.0", features = ["full"] } +tonic = { version = "0.8.2", features = ["tls-roots"] } +tracing = "0.1.41" +tracing-subscriber = "0.3.20" +tracing-opentelemetry = "0.32.0" [package.metadata.maturin] generate-abi-stubs = true diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index e7cd8aa..e6e5151 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "2.0.0" +version = "3.0.0" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index e4d3e02..bcde4ce 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -1,6 +1,12 @@ use chrono::{Duration, Local, NaiveDate}; use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; use mongodb::bson::oid::ObjectId; +use opentelemetry::global::shutdown_tracer_provider; +use opentelemetry::sdk::Resource; +use opentelemetry::trace::{Status, TraceContextExt, TraceError}; +use opentelemetry::{Context, KeyValue, sdk::trace as sdktrace, trace::Tracer}; +use opentelemetry::{Key, global}; +use opentelemetry_otlp::WithExportConfig; use pyo3::{prelude::*, types::PyString}; use reqwest::{ Client, @@ -17,6 +23,7 @@ use std::{ path::PathBuf, str::FromStr, }; +use tracing_subscriber::prelude::*; #[derive(Debug, Deserialize, Serialize)] struct ApiResponse { @@ -61,6 +68,12 @@ pub fn pull_policy_exec_histories( exec_types: String, days: i64, ) -> Py { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let _ = init_tracer(); + }); + let tracer = global::tracer("global_tracer"); + let _cx = Context::new(); let file_path: PathBuf = format!( "{}\\cache\\chunkinator.json", get_base_directory().display() @@ -93,102 +106,142 @@ pub fn pull_policy_exec_histories( .unwrap(), ); progress_bar.enable_steady_tick(std::time::Duration::from_millis(100)); - let client = build_client(py, &py_self); + let client = tracer.in_span("Building HTTP Client", |cx| { + let client_result = build_client(py, &py_self); + match client_result { + Ok(client_result) => { + cx.span().add_event( + "info", + vec![KeyValue::new( + "Client Built Successfully", + format!("{:?}", client_result), + )], + ); + client_result + } + Err(client_result) => { + cx.span().add_event( + "warn", + vec![KeyValue::new( + "Client Failed to Build", + format!("{:?}", &client_result), + )], + ); + cx.span() + .set_status(Status::error("Client Failed to Build")); + panic!("Failed to Build Client: {:?}", client_result); + } + } + }); let api: Py = py_self; let cutoff = Local::now().naive_local() - Duration::days(days); let mut f = File::open(&writeable_filepath).unwrap(); - loop { - f.seek(SeekFrom::Start(0)).unwrap(); - let execution_histories = history_logging( - py, - &api, - &exec_types, - &checkpoint_number, - &policy_names, - &client, - ); - let parsed_responses = execution_histories.response.exechistories; - if parsed_responses.is_empty() { - break; - } - let mut seen: HashMap<(String, String, String), Group> = if writeable_filepath.exists() { - let mut contents = String::new(); - f.read_to_string(&mut contents).unwrap(); - let existing_data: ApiResponse = - serde_json::from_str(&contents).unwrap_or(ApiResponse { - error: "Success".to_string(), - response: ExecHistories { - exechistories: vec![], - }, - }); - existing_data - .response - .exechistories - .into_iter() - .map(|entry| { - ( - ( - entry.sha256.clone(), - entry.filename.clone(), - entry.hostname.clone(), - ), - entry, - ) - }) - .collect() - } else { - HashMap::new() - }; - for (index, executions) in parsed_responses.iter().enumerate() { - if executions.checkpoint.is_empty() || executions.datetime.is_empty() { - continue; - } - if index == parsed_responses.len() - 1 { - checkpoint_number = executions.checkpoint.clone(); + tracer.in_span("Airlock Data Retreival", |cx| { + let span = cx.span(); + span.set_attribute(Key::new("Days").string(days.to_string().to_string())); + loop { + tracing::info!("Starting Airlock Data Retrieval"); + f.seek(SeekFrom::Start(0)).unwrap(); + let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| { + let results: ApiResponse = history_logging( + py, + &api, + &exec_types, + &checkpoint_number, + &policy_names, + &client, + ); + cx.span().set_attribute(KeyValue::new( + "Items in Response", + results.response.exechistories.len().to_string(), + )); + results + }); + let parsed_responses = execution_histories.response.exechistories; + if parsed_responses.is_empty() { break; } - let history_date = match NaiveDate::parse_from_str( - &executions.datetime.replace(" +0000 UTC", ""), - "%Y-%m-%dT%H:%M:%SZ", - ) { - Ok(date) => date, - Err(_) => continue, + let mut seen: HashMap<(String, String, String), Group> = if writeable_filepath.exists() + { + let mut contents = String::new(); + f.read_to_string(&mut contents).unwrap(); + let existing_data: ApiResponse = + serde_json::from_str(&contents).unwrap_or(ApiResponse { + error: "Success".to_string(), + response: ExecHistories { + exechistories: vec![], + }, + }); + existing_data + .response + .exechistories + .into_iter() + .map(|entry| { + ( + ( + entry.sha256.clone(), + entry.filename.clone(), + entry.hostname.clone(), + ), + entry, + ) + }) + .collect() + } else { + HashMap::new() }; - if history_date >= cutoff.into() { - let key = ( - executions.sha256.clone(), - executions.filename.clone(), - executions.hostname.clone(), - ); - seen.entry(key).or_insert(executions.clone()); + for (index, executions) in parsed_responses.iter().enumerate() { + if executions.checkpoint.is_empty() || executions.datetime.is_empty() { + continue; + } + if index == parsed_responses.len() - 1 { + checkpoint_number = executions.checkpoint.clone(); + break; + } + let history_date = match NaiveDate::parse_from_str( + &executions.datetime.replace(" +0000 UTC", ""), + "%Y-%m-%dT%H:%M:%SZ", + ) { + Ok(date) => date, + Err(_) => continue, + }; + if history_date >= cutoff.into() { + let key = ( + executions.sha256.clone(), + executions.filename.clone(), + executions.hostname.clone(), + ); + seen.entry(key).or_insert(executions.clone()); + } + } + let final_response = ApiResponse { + error: "Success".to_string(), + response: ExecHistories { + exechistories: seen.values().cloned().collect(), + }, + }; + let data_write = serde_json::to_string_pretty(&final_response).unwrap(); + fs::write(&writeable_filepath, data_write).unwrap(); + if let Some(last_item) = &final_response.response.exechistories.last() + && let Ok(last_date) = NaiveDate::parse_from_str( + &last_item.datetime.replace(" +0000 UTC", ""), + "%Y-%m-%dT%H:%M:%SZ", + ) + { + let date_diff = Local::now().naive_local().date() - last_date; + let percentage_diff = (days - date_diff.num_days()) as f64 / days as f64 * 100.0; + progress_bar.set_position(percentage_diff.round() as u64); + progress_bar.set_message("Total Percent Complete"); } } - let final_response = ApiResponse { - error: "Success".to_string(), - response: ExecHistories { - exechistories: seen.values().cloned().collect(), - }, - }; - let data_write = serde_json::to_string_pretty(&final_response).unwrap(); - fs::write(&writeable_filepath, data_write).unwrap(); - if let Some(last_item) = &final_response.response.exechistories.last() - && let Ok(last_date) = NaiveDate::parse_from_str( - &last_item.datetime.replace(" +0000 UTC", ""), - "%Y-%m-%dT%H:%M:%SZ", - ) - { - let date_diff = Local::now().naive_local().date() - last_date; - let percentage_diff = (days - date_diff.num_days()) as f64 / days as f64 * 100.0; - progress_bar.set_position(percentage_diff.round() as u64); - progress_bar.set_message("Total Percent Complete"); - } - } + }); progress_bar.finish_with_message("All Checkpoints Complete"); let return_data = fs::read_to_string(&writeable_filepath).unwrap(); + shutdown_tracer_provider(); PyString::new(py, &return_data).into() } -fn build_client(py: Python<'_>, py_self: &Py) -> Client { +fn build_client(py: Python<'_>, py_self: &Py) -> Result { let headers = py_self.getattr(py, "headers").unwrap().to_string(); let headers_replace = headers.replace('\'', "\""); let parsed: Value = serde_json::from_str(headers_replace.as_str()).unwrap(); @@ -207,7 +260,6 @@ fn build_client(py: Python<'_>, py_self: &Py) -> Client { .default_headers(header_map) .timeout(std::time::Duration::from_secs(300)) .build() - .unwrap() } #[tokio::main] @@ -276,3 +328,20 @@ fn skipback(days: i64) -> ObjectId { let objectid_hex = format!("{}0000000000000000", hex_timestamp); ObjectId::parse_str(&objectid_hex).expect("Invalid ObjectId hex") } + +fn init_tracer() -> Result { + opentelemetry_otlp::new_pipeline() + .tracing() + .with_exporter( + opentelemetry_otlp::new_exporter() + .tonic() + .with_endpoint("https://signoz.racooncity.org"), + ) + .with_trace_config( + sdktrace::config().with_resource(Resource::new(vec![KeyValue::new( + "service.name", + "LoxideLibs", + )])), + ) + .install_simple() +} From 6a5a2b5809edc8c8249c1a41076b587acda1ebaa Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Fri, 14 Nov 2025 13:42:35 -0500 Subject: [PATCH 16/29] Added airlock_libs 3.0.0 to requirements --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 65ee4c7..1468119 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==2.0.0 \ No newline at end of file +airlock_libs==3.0.0 \ No newline at end of file From ecdd991333287fc51d158e007e3c52c4b1c2ccc2 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Fri, 14 Nov 2025 13:44:43 -0500 Subject: [PATCH 17/29] Added protobuf-compiler to workflow --- .gitea/workflows/loxide_lib.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/loxide_lib.yml b/.gitea/workflows/loxide_lib.yml index 0a21747..a8b5039 100644 --- a/.gitea/workflows/loxide_lib.yml +++ b/.gitea/workflows/loxide_lib.yml @@ -14,7 +14,7 @@ jobs: - name: Install Prerequisites run: | apt update - apt install curl git python3 pip pkg-config openssl libssl-dev patchelf binutils-mingw-w64-x86-64 mingw-w64 -y + apt install curl git python3 pip pkg-config openssl libssl-dev patchelf binutils-mingw-w64-x86-64 mingw-w64 protobuf-compiler -y curl https://sh.rustup.rs -sSf | sh -s -- -y pip install maturin twine --break-system-packages From e36e5343d72c39f2a531b546d3c87dc3ed98a56f Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Fri, 14 Nov 2025 13:46:09 -0500 Subject: [PATCH 18/29] Added protobuf-compiler to workflow --- airlock_libs/airlock_libs.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airlock_libs/airlock_libs.pyi b/airlock_libs/airlock_libs.pyi index 137dc92..84bee85 100644 --- a/airlock_libs/airlock_libs.pyi +++ b/airlock_libs/airlock_libs.pyi @@ -30,7 +30,7 @@ def history_logging( checkpoint_number: str, policy_names: str, ) -> List[Dict[str, Any]]: - """ + """ Query execution history logs from the Airlock API. Parameters From 3ee762a0a145f5385179d48ef36ed4d6fd7bacb8 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Fri, 14 Nov 2025 17:07:49 -0500 Subject: [PATCH 19/29] Restructured TUI, expanded quietagent workflow --- Loxide.py | 2 +- Loxide_Icon.ico | Bin 0 -> 114411 bytes {widgets => TUI}/OTP_generate.py | 0 utils/tui.py => TUI/TUI.py | 42 +- {widgets => TUI}/agentmoveoperations.py | 26 +- TUI/allowlistselectionscreen.py | 616 +++++++++++++ {screens => TUI}/moveagentworkflowscreen.py | 6 +- {widgets => TUI}/multiagentselector.py | 0 {screens => TUI}/otpactivityscreen.py | 308 ++++++- {screens => TUI}/otpworkflowscreen.py | 2 +- {widgets => TUI}/policyselector.py | 5 +- {screens => TUI}/policyselectorscreen.py | 2 +- {widgets => TUI}/policytreewidget.py | 0 TUI/quietagentworkflowscreen.py | 848 ++++++++++++++++++ {widgets => TUI}/resultsdisplay.py | 0 .../theme_amber_terminal.py | 2 +- .../theme_retro_terminal.py | 0 {widgets => TUI}/themeselector.py | 0 flows/otp.py | 82 +- services/API.py | 13 + 20 files changed, 1770 insertions(+), 184 deletions(-) create mode 100644 Loxide_Icon.ico rename {widgets => TUI}/OTP_generate.py (100%) rename utils/tui.py => TUI/TUI.py (92%) rename {widgets => TUI}/agentmoveoperations.py (96%) create mode 100644 TUI/allowlistselectionscreen.py rename {screens => TUI}/moveagentworkflowscreen.py (92%) rename {widgets => TUI}/multiagentselector.py (100%) rename {screens => TUI}/otpactivityscreen.py (69%) rename {screens => TUI}/otpworkflowscreen.py (94%) rename {widgets => TUI}/policyselector.py (99%) rename {screens => TUI}/policyselectorscreen.py (98%) rename {widgets => TUI}/policytreewidget.py (100%) create mode 100644 TUI/quietagentworkflowscreen.py rename {widgets => TUI}/resultsdisplay.py (100%) rename themes/amber_terminal_theme.py => TUI/theme_amber_terminal.py (93%) rename themes/retro_terminal_theme.py => TUI/theme_retro_terminal.py (100%) rename {widgets => TUI}/themeselector.py (100%) diff --git a/Loxide.py b/Loxide.py index 47c9562..7fa448c 100644 --- a/Loxide.py +++ b/Loxide.py @@ -30,8 +30,8 @@ import urllib3 from services.API import AirlockAPIWrapper from services.security import getAPI +from TUI.TUI import run_Loxide from utils.setup import get_base_directory, setup -from utils.TUI import run_Loxide from utils.utils import irtang urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) diff --git a/Loxide_Icon.ico b/Loxide_Icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..dce491feea5ab384c0552600e5a5dcc404401361 GIT binary patch literal 114411 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 literal 0 HcmV?d00001 diff --git a/widgets/OTP_generate.py b/TUI/OTP_generate.py similarity index 100% rename from widgets/OTP_generate.py rename to TUI/OTP_generate.py diff --git a/utils/tui.py b/TUI/TUI.py similarity index 92% rename from utils/tui.py rename to TUI/TUI.py index 9f32133..990cde3 100644 --- a/utils/tui.py +++ b/TUI/TUI.py @@ -22,25 +22,25 @@ from textual.widgets import ( from flows.otp import otp_revoke from flows.prepPolicy import menu_policy_enforce -from flows.quietAgent import findQuietAgents from models.agent import Agent from models.policy import Policy -from screens.moveagentworkflowscreen import MoveAgentWorkflowScreen -from screens.otpactivityscreen import OTPActivitiesScreen -from screens.otpworkflowscreen import OTPWorkflowScreen from services.API import AirlockAPIWrapper from services.policyhandler import confirmUpdateAfromE -from themes.amber_terminal_theme import get_amber_terminal_theme -from themes.retro_terminal_theme import get_retro_terminal_theme +from TUI.agentmoveoperations import AgentMoveOperations +from TUI.moveagentworkflowscreen import MoveAgentWorkflowScreen +from TUI.multiagentselector import MultiAgentSelector +from TUI.OTP_generate import OTPGenerator +from TUI.otpactivityscreen import OTPActivitiesScreen +from TUI.otpworkflowscreen import OTPWorkflowScreen +from TUI.policytreewidget import PolicyTreeWidget +from TUI.quietagentworkflowscreen import QuietAgentWorkflowScreen +from TUI.resultsdisplay import ResultsDisplay +from TUI.theme_amber_terminal import get_amber_terminal_theme +from TUI.theme_retro_terminal import get_retro_terminal_theme +from TUI.themeselector import ThemeSelector from utils.configmanager import load_env from utils.setup import get_base_directory, load_user_config from utils.utils import open_directory -from widgets.agentmoveoperations import AgentMoveOperations -from widgets.multiagentselector import MultiAgentSelector -from widgets.OTP_generate import OTPGenerator -from widgets.policytreewidget import PolicyTreeWidget -from widgets.resultsdisplay import ResultsDisplay -from widgets.themeselector import ThemeSelector dotenv.load_dotenv() @@ -114,8 +114,8 @@ class MainMenuScreen(Screen): "πŸ–₯️ - Find, Move, or Generate OTP for Agents", "move_agent_workflow_button", ), - ("πŸ“Š - OTP Activities By Agent", "otp_activities_button"), - ("πŸ”‡ - Find Quiet Hosts", "find_quiet_button"), + ("πŸ“Š - Review and appove OTP Activities", "otp_activities_button"), + ("πŸ”‡ - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"), ], "policy": [ ("πŸ”’ - Prepare Policy For Enforcement", "policy_prep_button"), @@ -242,7 +242,6 @@ class MainMenuScreen(Screen): """Handle OTP generation request from the workflow.""" global _PENDING_JOB - # Log what we received logger.info( "OTP Generation requested: %d devices, requestor=%s, reason=%s, duration=%d", len(message.devices), @@ -251,7 +250,6 @@ class MainMenuScreen(Screen): message.duration, ) - # Set up the job to run the OTP generation _PENDING_JOB = ( "otp_workflow", message.devices, @@ -321,23 +319,21 @@ class MainMenuScreen(Screen): match button_id: case "move_agent_workflow_button": - # Push Move Agent workflow screen self.app.push_screen(MoveAgentWorkflowScreen(self.app.devices)) event.stop() - return # Don't exit the app case "otp_generate_button": - # NEW: Push OTP workflow screen instead of legacy function self.app.push_screen(OTPWorkflowScreen(self.app.devices)) event.stop() - return # Don't exit the app case "find_quiet_button": - _PENDING_JOB = ("legacy", findQuietAgents, (self.app.api,), {}) + self.app.push_screen( + QuietAgentWorkflowScreen(self.app.api, self.app.policies) + ) + event.stop() + return case "otp_activities_button": - # === FIXED: push the Textual OTPActivitiesScreen and return immediately === - # This must return so we don't fall through to the code that exits the app. self.app.push_screen(OTPActivitiesScreen()) event.stop() return diff --git a/widgets/agentmoveoperations.py b/TUI/agentmoveoperations.py similarity index 96% rename from widgets/agentmoveoperations.py rename to TUI/agentmoveoperations.py index 03e5da4..d3615f0 100644 --- a/widgets/agentmoveoperations.py +++ b/TUI/agentmoveoperations.py @@ -1,23 +1,3 @@ -""" -Agent Move Operations Widget Module - -This module provides a Textual-based UI widget for performing bulk operations on -agent devices in the Airlock system. It allows users to: -- View selected agents and their current policy assignments -- Move agents to local approval mode with OTP enforcement -- Toggle agents between audit and enforcement policy modes -- Select and move agents to alternate policies (future implementation) - -The widget tracks operation state, manages button availability, and displays -results with success/failure summaries that can be copied to clipboard. - -Dependencies: - - textual: TUI framework for building the widget and UI components - - models.agent: Agent model class - - services.agenthandler: Core agent operation functions - - flows.localApproval: Local approval workflow handling -""" - from dataclasses import asdict from datetime import datetime import logging @@ -33,9 +13,9 @@ from textual.widget import Widget from textual.widgets import Button, DataTable, Header, Static, TextArea from models.agent import Agent -from screens.otpworkflowscreen import OTPWorkflowScreen -from screens.policyselectorscreen import PolicySelectorScreen -from widgets.OTP_generate import OTPGenerator +from TUI.OTP_generate import OTPGenerator +from TUI.otpworkflowscreen import OTPWorkflowScreen +from TUI.policyselectorscreen import PolicySelectorScreen logger = logging.getLogger(__name__) diff --git a/TUI/allowlistselectionscreen.py b/TUI/allowlistselectionscreen.py new file mode 100644 index 0000000..fd84f20 --- /dev/null +++ b/TUI/allowlistselectionscreen.py @@ -0,0 +1,616 @@ +from __future__ import annotations + +import logging +from typing import Optional + +import pandas as pd +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import ( + Button, + DataTable, + Footer, + Header, + Static, + TextArea, +) + +logger = logging.getLogger(__name__) + + +class AllowlistSelectionWidget(Static): + """ + Widget for selecting an allowlist and adding hashes to it. + Can be reused in different workflows. + """ + + DEFAULT_CSS = """ + AllowlistSelectionWidget { + height: 1fr; + layout: vertical; + } + #allowlist_main { + height: 100%; + width: 100%; + } + #left_panel { + width: 50%; + padding: 1; + border: solid $primary; + } + #right_panel { + width: 50%; + padding: 1; + border: solid $primary; + } + #allowlist_table { + height: 70%; + margin: 1 0; + } + #allowlist_table > .datatable--header { + text-style: bold; + background: $boost; + } + #allowlist_table Row { + height: 1; + } + #preview_area { + height: 60%; + margin: 1 0; + } + #action_buttons { + height: 10%; + padding: 1; + content-align: center middle; + } + .panel-title { + text-style: bold; + margin: 0 0 1 0; + } + .info-text { + margin: 1 0; + } + """ + + def __init__( + self, + selected_data: pd.DataFrame, + api=None, + hostname: Optional[str] = None, + otpid: Optional[str] = None, + hash_column: str = "sha256", # Default hash column name + ): + """ + Initialize the allowlist selection widget. + + Args: + selected_data: DataFrame containing the selected activities + api: API instance for making allowlist calls + hostname: Optional hostname for context + otpid: Optional OTP ID for context + hash_column: Name of the column containing hashes (default: "sha256") + """ + super().__init__() + self.selected_data = selected_data + self.api = api + self.hostname = hostname + self.otpid = otpid + self.hash_column = hash_column + self.allowlists = [] + self.selected_allowlist = None + self.hashes_to_add = [] + + def compose(self) -> ComposeResult: + with Horizontal(id="allowlist_main"): + # Left panel - Allowlist selection + with Vertical(id="left_panel"): + yield Static("Select Allowlist", classes="panel-title") + yield Static( + f"Choose an allowlist to add {len(self.selected_data)} selected items", + classes="info-text", + ) + + # Allowlist table + self.allowlist_table = DataTable(id="allowlist_table") + self.allowlist_table.cursor_type = "row" + yield self.allowlist_table + + # Refresh button + self.refresh_btn = Button( + "πŸ”„ Refresh Allowlists", id="refresh_allowlists_btn" + ) + yield self.refresh_btn + + # Right panel - Preview and actions + with Vertical(id="right_panel"): + yield Static("Preview", classes="panel-title") + + # Context information + context_text = [] + if self.hostname: + context_text.append(f"Host: {self.hostname}") + if self.otpid: + context_text.append(f"OTP: {self.otpid}") + context_text.append(f"Selected Activities: {len(self.selected_data)}") + + yield Static(" | ".join(context_text), classes="info-text") + + # Preview text area + self.preview_area = TextArea( + id="preview_area", read_only=True, language="markdown" + ) + yield self.preview_area + + # Hash statistics + self.stats_label = Static("", id="stats_label", classes="info-text") + yield self.stats_label + + # Action buttons at bottom + with Horizontal(id="action_buttons"): + self.back_btn = Button("← Back", id="back_btn") + self.add_btn = Button("βž• Add to Allowlist", id="add_to_allowlist_btn") + + self.back_btn.styles.width = "50%" + self.add_btn.styles.width = "50%" + self.add_btn.disabled = True # Disabled until allowlist selected + + yield self.back_btn + yield self.add_btn + + async def on_mount(self) -> None: + """Load allowlists when widget mounts.""" + await self.load_allowlists() + await self.extract_and_preview_hashes() + + async def load_allowlists(self) -> None: + """Load available allowlists from API, grouped by policy association.""" + if not self.api: + logger.error("No API available") + self.allowlist_table.add_column("Error") + self.allowlist_table.add_row("No API available") + return + + try: + # First, try to get the host's policy if hostname is provided + host_policy_allowlists = [] + host_policy_ids = set() + policy_name = None + + if self.hostname: + try: + # Get agent info to find its policy + agents_df = self.api.agent_find_by_hostname(self.hostname) + if not agents_df.empty: + # Get the policy group ID for this host + group_id = agents_df.iloc[0].get("groupid") + policy_name = agents_df.iloc[0].get( + "groupname", "Unknown Policy" + ) + + if group_id: + # Get allowlists for this policy + policy_allowlists_df = self.api.policy_list_allowlists( + group_id + ) + if not policy_allowlists_df.empty: + host_policy_allowlists = policy_allowlists_df.to_dict( + orient="records" + ) + host_policy_ids = { + al.get("applicationid") + for al in host_policy_allowlists + } + logger.info( + f"Found {len(host_policy_allowlists)} allowlists for host's policy" + ) + except Exception as e: + logger.warning(f"Could not get host's policy allowlists: {e}") + + # Get all allowlists + all_allowlists_df = self.api.allowlist_find_all() + + if all_allowlists_df.empty: + self.allowlist_table.add_column("No Allowlists") + self.allowlist_table.add_row("No allowlists found") + return + + all_allowlists = all_allowlists_df.to_dict(orient="records") + + # Separate into two groups: policy-associated and others + other_allowlists = [ + al + for al in all_allowlists + if al.get("applicationid") not in host_policy_ids + ] + + # Sort each group alphabetically by name + host_policy_allowlists.sort(key=lambda x: x.get("name", "").lower()) + other_allowlists.sort(key=lambda x: x.get("name", "").lower()) + + # Combine lists with policy-associated first + self.allowlists = host_policy_allowlists + other_allowlists + + # Setup table columns + self.allowlist_table.clear() + self.allowlist_table.add_columns("Name", "Application ID", "Type") + + # Track which rows are headers vs actual allowlists + self._row_to_allowlist_map = {} + current_row = 0 + + # Add policy-associated allowlists if any + if host_policy_allowlists: + # Add section header + header_text = f"━━━ Policy: {policy_name or 'Host Policy'} ━━━" + self.allowlist_table.add_row(header_text, "", "", key="header_policy") + current_row += 1 + + # Add policy allowlists + for idx, allowlist in enumerate(host_policy_allowlists): + name = allowlist.get("name", "Unknown") + app_id = allowlist.get("applicationid", "Unknown") + + self.allowlist_table.add_row( + f" {name}", # Indent to show grouping + app_id, + "Policy", + key=f"policy_{idx}", + ) + self._row_to_allowlist_map[current_row] = idx + current_row += 1 + + # Add other allowlists + if other_allowlists: + # Add section header + if host_policy_allowlists: + # Add spacer if we have policy allowlists above + self.allowlist_table.add_row("", "", "", key="spacer") + current_row += 1 + + self.allowlist_table.add_row( + "━━━ Other Available Allowlists ━━━", "", "", key="header_other" + ) + current_row += 1 + + # Add other allowlists + for idx, allowlist in enumerate(other_allowlists): + name = allowlist.get("name", "Unknown") + app_id = allowlist.get("applicationid", "Unknown") + + self.allowlist_table.add_row( + f" {name}", # Indent to show grouping + app_id, + "General", + key=f"other_{idx}", + ) + # Map to the correct index in the combined list + actual_idx = len(host_policy_allowlists) + idx + self._row_to_allowlist_map[current_row] = actual_idx + current_row += 1 + + # Log summary + logger.info( + f"Loaded {len(self.allowlists)} total allowlists: " + f"{len(host_policy_allowlists)} policy-associated, " + f"{len(other_allowlists)} others" + ) + + # Update stats label if no allowlists in policy + if self.hostname and not host_policy_allowlists: + self.stats_label.update( + f"Note: No allowlists found for {self.hostname}'s policy | " + + self.stats_label.content.plain + ) + + except Exception as exc: + logger.exception(f"Failed to load allowlists: {exc}") + self.allowlist_table.add_column("Error") + self.allowlist_table.add_row(f"Failed to load: {str(exc)}") + + async def extract_and_preview_hashes(self) -> None: + """Extract hashes from selected data and show preview.""" + preview_lines = ["## Hash Extraction Summary\n"] + + # Check for hash column + if self.hash_column not in self.selected_data.columns: + # Try to find a hash column + possible_hash_cols = [ + "sha256", + "SHA256", + "hash", + "Hash", + "sha1", + "SHA1", + "md5", + "MD5", + "filehash", + "file_hash", + ] + found_col = None + for col in possible_hash_cols: + if col in self.selected_data.columns: + found_col = col + break + + if found_col: + self.hash_column = found_col + preview_lines.append(f"βœ“ Found hash column: **{found_col}**\n") + else: + preview_lines.append("⚠️ **No hash column found**\n") + preview_lines.append("Available columns:\n") + for col in self.selected_data.columns: + if col != "_row_id": + preview_lines.append(f" - {col}\n") + + self.preview_area.text = "".join(preview_lines) + self.stats_label.update("No hashes to add") + return + + # Extract unique hashes + hashes = self.selected_data[self.hash_column].dropna().unique() + self.hashes_to_add = [h for h in hashes if h and str(h).strip()] + + # Build preview + preview_lines.append(f"### Found {len(self.hashes_to_add)} unique hashes\n\n") + + # Show sample of hashes (first 10) + preview_lines.append("**Sample hashes to be added:**\n```\n") + for i, hash_val in enumerate(self.hashes_to_add[:10]): + preview_lines.append(f"{i+1}. {hash_val}\n") + if len(self.hashes_to_add) > 10: + preview_lines.append(f"... and {len(self.hashes_to_add) - 10} more\n") + preview_lines.append("```\n\n") + + # Show sample of source data + preview_lines.append("**Sample source activities:**\n") + sample_cols = [ + col + for col in self.selected_data.columns + if col not in ["_row_id"] and col in ["filename", "path", "action", "user"] + ] + if not sample_cols: + sample_cols = [ + col for col in self.selected_data.columns if col != "_row_id" + ][:3] + + if sample_cols: + preview_lines.append("```\n") + for i, row in self.selected_data[sample_cols].head(5).iterrows(): + row_text = " | ".join([f"{col}: {row[col]}" for col in sample_cols]) + preview_lines.append(f"{row_text}\n") + preview_lines.append("```\n") + + self.preview_area.text = "".join(preview_lines) + + # Update statistics + self.stats_label.update( + f"Ready to add {len(self.hashes_to_add)} unique hashes | " + f"From {len(self.selected_data)} selected activities" + ) + + async def on_data_table_row_selected(self, event) -> None: + """Handle allowlist selection.""" + try: + # Extract row index from event - handle different event structures + row_index = None + + # Try to get row index from coordinate + if hasattr(event, "coordinate") and hasattr(event.coordinate, "row"): + row_index = event.coordinate.row + # Try cursor_row as fallback + elif hasattr(event, "cursor_row"): + row_index = event.cursor_row + # Try getting from the table itself + else: + table = self.allowlist_table + if hasattr(table, "cursor_row"): + row_index = table.cursor_row + + # Validate row index + if row_index is not None and isinstance(row_index, int): + # Account for group headers in the row count + actual_allowlist_index = self._get_allowlist_index_from_row(row_index) + + if ( + actual_allowlist_index is not None + and 0 <= actual_allowlist_index < len(self.allowlists) + ): + self.selected_allowlist = self.allowlists[actual_allowlist_index] + self.add_btn.disabled = False + self.add_btn.label = ( + f"βž• Add to '{self.selected_allowlist.get('name', 'Unknown')}'" + ) + + # Update preview with selection + await self._update_preview_with_selection() + + logger.info( + f"Selected allowlist: {self.selected_allowlist.get('name')}" + ) + else: + logger.debug(f"Row {row_index} is a header or invalid") + else: + logger.warning(f"Could not extract valid row index from event: {event}") + + except Exception as exc: + logger.exception(f"Failed to select allowlist: {exc}") + + def _get_allowlist_index_from_row(self, row_index: int) -> Optional[int]: + """Convert table row index to allowlist list index, accounting for group headers.""" + # This will be updated when we have group headers + if hasattr(self, "_row_to_allowlist_map"): + return self._row_to_allowlist_map.get(row_index) + return row_index + + async def _update_preview_with_selection(self) -> None: + """Update preview when an allowlist is selected.""" + if not self.selected_allowlist: + return + + current_text = self.preview_area.text + # Remove any existing selection header + if "### Selected Allowlist:" in current_text: + lines = current_text.split("\n") + # Find and remove the selection lines + new_lines = [] + skip_next = False + for line in lines: + if line.startswith("### Selected Allowlist:"): + skip_next = True + continue + if skip_next and line.startswith("Application ID:"): + skip_next = False + continue + if not skip_next: + new_lines.append(line) + current_text = "\n".join(new_lines) + + # Add new selection at the top + selection_text = ( + f"### Selected Allowlist: **{self.selected_allowlist.get('name')}**\n" + f"Application ID: {self.selected_allowlist.get('applicationid')}\n\n" + ) + self.preview_area.text = selection_text + current_text + + async def on_button_pressed(self, event) -> None: + """Handle button presses.""" + btn = getattr(event, "button", None) or getattr(event, "sender", None) + btn_id = getattr(btn, "id", None) or getattr(event, "button_id", None) + + if btn is self.back_btn or btn_id == "back_btn": + await self.app.pop_screen() + event.stop() + return + + if btn is self.refresh_btn or btn_id == "refresh_allowlists_btn": + await self.load_allowlists() + event.stop() + return + + if btn is self.add_btn or btn_id == "add_to_allowlist_btn": + await self.add_hashes_to_allowlist() + event.stop() + return + + async def add_hashes_to_allowlist(self) -> None: + """Add the extracted hashes to the selected allowlist.""" + if not self.selected_allowlist or not self.hashes_to_add: + self.app.notify( + "No allowlist selected or no hashes to add", severity="warning" + ) + return + + if not self.api: + self.app.notify("API not available", severity="error") + return + + try: + # Disable button during operation + self.add_btn.disabled = True + self.add_btn.label = "⏳ Adding hashes..." + + # Call API to add hashes + app_id = self.selected_allowlist.get("applicationid") + allowlist_name = self.selected_allowlist.get("name", "Unknown") + + logger.info( + f"Adding {len(self.hashes_to_add)} hashes to allowlist {allowlist_name} (ID: {app_id})" + ) + + result = self.api.hash_add_to_allowlist(app_id, self.hashes_to_add) + + # Success notification + self.app.notify( + f"βœ… Successfully added {len(self.hashes_to_add)} hashes to '{allowlist_name}'", + title="Success", + severity="information", + timeout=5, + ) + + # Update preview to show success + self.preview_area.text = ( + f"## βœ… SUCCESS\n\n" + f"Added **{len(self.hashes_to_add)} hashes** to allowlist:\n" + f"**{allowlist_name}** (ID: {app_id})\n\n" + f"### Operation Details:\n" + f"- Source: {self.hostname or 'Multiple hosts'}\n" + f"- OTP ID: {self.otpid or 'N/A'}\n" + f"- Activities processed: {len(self.selected_data)}\n" + f"- Unique hashes added: {len(self.hashes_to_add)}\n" + ) + + # Change button to "Done" + self.add_btn.label = "βœ… Done - Close" + self.add_btn.disabled = False + + # When clicked again, close the screen + self.add_btn_success = True + + except Exception as exc: + logger.exception(f"Failed to add hashes to allowlist: {exc}") + self.app.notify( + f"❌ Failed to add hashes: {str(exc)}", + title="Error", + severity="error", + timeout=10, + ) + + # Re-enable button + self.add_btn.disabled = False + self.add_btn.label = "βž• Retry Add to Allowlist" + + +class AllowlistSelectionScreen(Screen): + """ + Screen wrapper for the AllowlistSelectionWidget. + """ + + BINDINGS = [ + Binding("b", "back", "Back"), + Binding("r", "refresh", "Refresh Allowlists"), + Binding("enter", "confirm", "Add to Allowlist"), + ] + + def __init__( + self, + selected_data: pd.DataFrame, + api=None, + hostname: Optional[str] = None, + otpid: Optional[str] = None, + hash_column: str = "sha256", + ): + super().__init__() + self.selected_data = selected_data + self.api = api + self.hostname = hostname + self.otpid = otpid + self.hash_column = hash_column + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + self.widget = AllowlistSelectionWidget( + self.selected_data, + api=self.api, + hostname=self.hostname, + otpid=self.otpid, + hash_column=self.hash_column, + ) + yield self.widget + yield Footer() + + async def action_back(self) -> None: + """Go back to previous screen.""" + await self.app.pop_screen() + + async def action_refresh(self) -> None: + """Refresh the allowlists.""" + if hasattr(self, "widget") and self.widget: + await self.widget.load_allowlists() + + async def action_confirm(self) -> None: + """Confirm and add to allowlist.""" + if hasattr(self, "widget") and self.widget: + if self.widget.selected_allowlist and self.widget.hashes_to_add: + await self.widget.add_hashes_to_allowlist() diff --git a/screens/moveagentworkflowscreen.py b/TUI/moveagentworkflowscreen.py similarity index 92% rename from screens/moveagentworkflowscreen.py rename to TUI/moveagentworkflowscreen.py index da96331..8398ddf 100644 --- a/screens/moveagentworkflowscreen.py +++ b/TUI/moveagentworkflowscreen.py @@ -4,9 +4,9 @@ from textual.app import ComposeResult from textual.screen import Screen from models.agent import Agent -from widgets.agentmoveoperations import AgentMoveOperations -from widgets.multiagentselector import MultiAgentSelector -from widgets.resultsdisplay import ResultsDisplay +from TUI.agentmoveoperations import AgentMoveOperations +from TUI.multiagentselector import MultiAgentSelector +from TUI.resultsdisplay import ResultsDisplay class MoveAgentWorkflowScreen(Screen): diff --git a/widgets/multiagentselector.py b/TUI/multiagentselector.py similarity index 100% rename from widgets/multiagentselector.py rename to TUI/multiagentselector.py diff --git a/screens/otpactivityscreen.py b/TUI/otpactivityscreen.py similarity index 69% rename from screens/otpactivityscreen.py rename to TUI/otpactivityscreen.py index cc98655..84847e0 100644 --- a/screens/otpactivityscreen.py +++ b/TUI/otpactivityscreen.py @@ -11,6 +11,7 @@ from textual.containers import Horizontal, Vertical from textual.screen import Screen from textual.widgets import Button, DataTable, Footer, Header, Static +from TUI.allowlistselectionscreen import AllowlistSelectionScreen from utils.configmanager import load_env logger = logging.getLogger(__name__) @@ -134,14 +135,12 @@ class OTPActivitiesWidget(Static): or getattr(event, "button_id", None) or getattr(event, "id", None) ) - # ---- Back ---- if btn is self.back_btn or btn_id == getattr(self.back_btn, "id", None): while len(self.app.screen_stack) > 2: self.app.pop_screen() event.stop() return - # ---- Continue ---- if btn is self.continue_btn or btn_id == getattr(self.continue_btn, "id", None): if self._activities_df is None or self._activities_df.empty: @@ -172,7 +171,6 @@ class OTPActivitiesWidget(Static): except Exception as exc: logger.exception("Failed to push ActivityDetailScreen: %s", exc) return - # Unknown button on widget logger.debug( "Unhandled OTPActivitiesWidget button pressed (resolved btn=%r, id=%r)", @@ -212,7 +210,6 @@ class OTPActivitiesWidget(Static): row_key = getattr(event, attr, None) if row_key is not None: break - # If coordinate: try to extract .row or tuple[0] if row_key is None: coord = getattr(event, "coordinate", None) or getattr( @@ -223,7 +220,6 @@ class OTPActivitiesWidget(Static): row_key = coord.row elif isinstance(coord, (tuple, list)) and len(coord) >= 1: row_key = coord[0] - # If still nothing, maybe the event provides the row's cell values directly row_values = None for attr in ("values", "cells", "row", "row_values", "selected_row_values"): @@ -232,7 +228,6 @@ class OTPActivitiesWidget(Static): # Prefer actual sequence of cell values row_values = val break - # If we have row_values, try to map them back to the sessions DataFrame if row_values is not None: # Normalize into list of strings for comparison @@ -323,7 +318,6 @@ class OTPActivitiesWidget(Static): # Helpful debug hint for you to paste back if still failing: logger.debug("Event repr for debugging: %r", event) return - # At this point we should have an integer idx try: idx = int(idx) @@ -332,7 +326,6 @@ class OTPActivitiesWidget(Static): "Final normalization of selected row index failed: %r", idx ) return - # Validate sessions df if self._sessions_df is None or self._sessions_df.empty: logger.warning("Sessions DataFrame empty; nothing to select.") @@ -479,6 +472,7 @@ class ActivityDetailWidget(Static): """ Interactive widget for Activity Detail screen. Shows the provided DataFrame in a DataTable and offers Export + Back buttons. + Now includes Select All/None and Add to Allowlist functionality. """ DEFAULT_CSS = """ @@ -487,9 +481,14 @@ class ActivityDetailWidget(Static): layout: vertical; } #detail_table_container { - height: 85%; + height: 75%; padding: 1 1; } + #selection_buttons { + height: 10%; + padding: 1 1; + content-align: center middle; + } #detail_buttons { height: 15%; padding: 1 1; @@ -504,8 +503,16 @@ class ActivityDetailWidget(Static): if isinstance(activities_df, pd.DataFrame) else pd.DataFrame(activities_df) ) + # Add a unique identifier column if not present + if "_row_id" not in self.activities_df.columns: + self.activities_df["_row_id"] = range(len(self.activities_df)) + self.otpid = otpid self.hostname = hostname + self.selected_row_ids = set() # Track selected rows by unique ID + self.row_key_to_id = {} # Map DataTable row keys to unique row IDs + self.table_row_to_id = {} # Map table row indices to unique row IDs + self._last_sort = None # Track last sort column and order def compose(self) -> ComposeResult: yield Static( @@ -516,73 +523,267 @@ class ActivityDetailWidget(Static): with Vertical(id="detail_table_container"): self.detail_table = DataTable(id="detail_table") yield self.detail_table - # Buttons at bottom + + # Original buttons at bottom with Horizontal(id="detail_buttons"): self.detail_back_btn = Button("Back", id="detail_back_btn") - self.detail_export_btn = Button("Export (CSV)", id="detail_export_btn") - # Make them stretch equally - self.detail_back_btn.styles.width = "50%" - self.detail_export_btn.styles.width = "50%" + self.add_allowlist_btn = Button( + "πŸ“‹ Add Selected to Allowlist", id="add_allowlist_btn" + ) + yield self.add_allowlist_btn yield self.detail_back_btn - yield self.detail_export_btn async def on_mount(self) -> None: - # Populate table from activities_df - self.detail_table.clear() - if self.activities_df is None or self.activities_df.empty: - logger.info("ActivityDetailWidget mounted with empty dataframe.") - return - # Add columns - for col in self.activities_df.columns: - self.detail_table.add_column(col) + await self._build_table(rebuild=True) + self._update_button_states() + + def _update_button_states(self) -> None: + """Update button states based on selection.""" + has_selection = len(self.selected_row_ids) > 0 + self.add_allowlist_btn.disabled = not has_selection + + # Update button labels with count + count = len(self.selected_row_ids) + total = len(self.activities_df) + + if has_selection: + self.add_allowlist_btn.label = f"πŸ“‹ Add {count} Selected to Allowlist" + else: + self.add_allowlist_btn.label = "πŸ“‹ Add Selected to Allowlist" + + async def _build_table(self, rebuild: bool = True) -> None: + """Rebuild the DataTable. If rebuild=False, only refresh rows.""" + if rebuild: + # Full rebuild: clear columns and rows + self.detail_table.clear() + self.detail_table.columns.clear() + self.row_key_to_id.clear() + self.table_row_to_id.clear() + + if self.activities_df is None or self.activities_df.empty: + logger.info("ActivityDetailWidget mounted with empty dataframe.") + return + + # Add columns (checkbox + data columns, excluding internal _row_id) + self.detail_table.add_column("Select", key="select") + for col in self.activities_df.columns: + if col != "_row_id": # Don't display the internal ID column + self.detail_table.add_column(col) + else: + # Partial rebuild: clear rows only + self.detail_table.clear() + self.row_key_to_id.clear() + self.table_row_to_id.clear() + # Add rows - for _, row in self.activities_df.iterrows(): - vals = ["" if pd.isna(v) else v for v in row.to_list()] - self.detail_table.add_row(*[str(v) for v in vals]) - # Allow sorting / cursor - self.detail_table.cursor_type = "row" + for table_idx, (df_idx, row) in enumerate(self.activities_df.iterrows()): + # Get the unique row ID + row_id = row["_row_id"] + + # Build values list (excluding _row_id column) + vals = [] + for col in self.activities_df.columns: + if col != "_row_id": + v = row[col] + vals.append("" if pd.isna(v) else str(v)) + + # Check if this row is selected + checkbox = "β˜‘" if row_id in self.selected_row_ids else "☐" + + # Add row to table + row_key = self.detail_table.add_row(checkbox, *vals) + + # Map the row key and table index to the unique row ID + self.row_key_to_id[row_key] = row_id + self.table_row_to_id[table_idx] = row_id + + async def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: + # Toggle selection when the "Select" column is clicked + if event.cell_key.column_key.value == "select": + table_row_index = event.coordinate.row + + # Get the unique row ID for this table row + row_id = self.table_row_to_id.get(table_row_index) + if row_id is not None: + # Get the row key for updating the cell + row_key = event.cell_key.row_key + + if row_id in self.selected_row_ids: + self.selected_row_ids.remove(row_id) + self.detail_table.update_cell(row_key, "select", "☐") # Unchecked + else: + self.selected_row_ids.add(row_id) + self.detail_table.update_cell(row_key, "select", "β˜‘") # Checked + + self._update_button_states() + + async def on_data_table_header_selected( + self, event: DataTable.HeaderSelected + ) -> None: + column_key = event.column_key.value if event.column_key else None + if not column_key: + col_index = event.column_index + if col_index == 0: # First column is "Select" + return + # Adjust for hidden _row_id column + visible_cols = [ + col for col in self.activities_df.columns if col != "_row_id" + ] + if col_index - 1 < len(visible_cols): + column_key = visible_cols[col_index - 1] + else: + return + if column_key == "select" or column_key == "_row_id": + return + + ascending = True + if self._last_sort == (column_key, True): + ascending = False + self._last_sort = (column_key, ascending) + + try: + self.activities_df.sort_values( + by=column_key, ascending=ascending, inplace=True + ) + except Exception as exc: + logger.exception("Failed to sort by column %s: %s", column_key, exc) + return + + # βœ… Only refresh rows, not columns + await self._build_table(rebuild=False) async def on_button_pressed(self, event) -> None: btn = getattr(event, "button", None) or getattr(event, "sender", None) btn_id = getattr(btn, "id", None) or getattr(event, "button_id", None) - # Back button in ActivityDetailWidget if btn is self.detail_back_btn or btn_id == "detail_back_btn": - # Pop screens until only the main menu remains while len(self.app.screen_stack) > 2: self.app.pop_screen() event.stop() return - # Export button - if btn is self.detail_export_btn or btn_id == "detail_export_btn": - await self._export_detail_activities() + + if btn is self.add_allowlist_btn or btn_id == "add_allowlist_btn": + await self._open_allowlist_screen() return - async def _export_detail_activities(self) -> None: + async def _select_all(self) -> None: + """Select all rows in the table.""" + # Add all row IDs to selected set + self.selected_row_ids = set(self.activities_df["_row_id"].tolist()) + # Update all checkboxes in the table + for row_key, row_id in self.row_key_to_id.items(): + self.detail_table.update_cell(row_key, "select", "β˜‘") + + self._update_button_states() + logger.info(f"Selected all {len(self.selected_row_ids)} rows") + + async def _select_none(self) -> None: + """Deselect all rows in the table.""" + # Clear selected set + self.selected_row_ids.clear() + + # Update all checkboxes in the table + for row_key, row_id in self.row_key_to_id.items(): + self.detail_table.update_cell(row_key, "select", "☐") + + self._update_button_states() + logger.info("Cleared all selections") + + async def _open_allowlist_screen(self) -> None: + """Open the allowlist selection screen with selected activities.""" + if not self.selected_row_ids: + self.app.notify("No rows selected", severity="warning") + return + + # Get selected data + selected_df = self.get_selected_data() + + # Get API from app + api = getattr(self.app, "api", None) + if api is None: + logger.error("No API available on self.app.api") + self.app.notify("API not available", severity="error") + return + + # Create and push AllowlistSelectionScreen + try: + allowlist_screen = AllowlistSelectionScreen( + selected_df, api=api, hostname=self.hostname, otpid=self.otpid + ) + await self.app.push_screen(allowlist_screen) + logger.info( + f"Opened allowlist screen with {len(selected_df)} selected activities" + ) + except ImportError as e: + logger.error(f"Failed to import AllowlistSelectionScreen: {e}") + self.app.notify("Allowlist screen module not found", severity="error") + except Exception as e: + logger.exception(f"Failed to open allowlist screen: {e}") + self.app.notify( + f"Error opening allowlist screen: {str(e)}", severity="error" + ) + + async def _export_detail_activities(self) -> None: if self.activities_df is None or self.activities_df.empty: logger.info("No activities to export.") - notification = Static("❌ No activities to export.", classes="notification") - self.mount(notification) + await self.mount( + Static("❌ No activities to export.", classes="notification") + ) + return + if not self.selected_row_ids: + logger.info("No rows selected for export.") + await self.mount( + Static("❌ No rows selected for export.", classes="notification") + ) return try: working_dir = load_env("WORKING_DIR") or os.getcwd() timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") filename = f"otp_activities_detail_{timestamp}.csv" file_path = os.path.join(working_dir, filename) - self.activities_df.to_csv(file_path, index=False) - logger.info("Exported detail activities to %s", file_path) - # Show success notification - notification = Static( - f"βœ… Exported activities to: {filename}", classes="notification" + selected_df = self.get_selected_data() + selected_df.to_csv(file_path, index=False) + logger.info("Exported selected activities to %s", file_path) + await self.mount( + Static( + f"βœ… Exported selected activities to: {filename}", + classes="notification", + ) ) - self.mount(notification) except Exception as exc: logger.exception("Failed to export detail activities: %s", exc) - notification = Static( - "❌ Failed to export activities; check logs.", classes="notification" + await self.mount( + Static( + "❌ Failed to export activities; check logs.", + classes="notification", + ) ) - self.mount(notification) + + # βœ… Helper methods + def get_selected_data(self) -> pd.DataFrame: + """Return a DataFrame of the selected rows.""" + if not self.selected_row_ids: + return pd.DataFrame() + # Filter by selected row IDs and drop the internal _row_id column + selected_df = self.activities_df[ + self.activities_df["_row_id"].isin(self.selected_row_ids) + ].copy() + if "_row_id" in selected_df.columns: + selected_df = selected_df.drop(columns=["_row_id"]) + return selected_df + + def get_selected_records(self) -> list[dict]: + """Return selected rows as a list of dicts.""" + if not self.selected_row_ids: + return [] + # Filter by selected row IDs and drop the internal _row_id column + selected_df = self.activities_df[ + self.activities_df["_row_id"].isin(self.selected_row_ids) + ].copy() + if "_row_id" in selected_df.columns: + selected_df = selected_df.drop(columns=["_row_id"]) + return selected_df.to_dict(orient="records") class ActivityDetailScreen(Screen): @@ -590,7 +791,12 @@ class ActivityDetailScreen(Screen): Screen that wraps ActivityDetailWidget. Expects a DataFrame passed on init. """ - BINDINGS = [Binding("b", "back", "Back"), Binding("e", "export", "Export")] + BINDINGS = [ + Binding("b", "back", "Back"), + Binding("e", "export", "Export"), + Binding("a", "select_all", "Select All"), + Binding("n", "select_none", "Select None"), + ] def __init__(self, activities_df: pd.DataFrame, otpid=None, hostname=None) -> None: super().__init__() @@ -621,6 +827,16 @@ class ActivityDetailScreen(Screen): if hasattr(self, "widget") and self.widget is not None: await self.widget._export_detail_activities() + async def action_select_all(self) -> None: + """Handle 'a' key for select all.""" + if hasattr(self, "widget") and self.widget is not None: + await self.widget._select_all() + + async def action_select_none(self) -> None: + """Handle 'n' key for select none.""" + if hasattr(self, "widget") and self.widget is not None: + await self.widget._select_none() + class OTPActivitiesScreen(Screen): """ diff --git a/screens/otpworkflowscreen.py b/TUI/otpworkflowscreen.py similarity index 94% rename from screens/otpworkflowscreen.py rename to TUI/otpworkflowscreen.py index 519ff6b..d7d7322 100644 --- a/screens/otpworkflowscreen.py +++ b/TUI/otpworkflowscreen.py @@ -6,7 +6,7 @@ from textual.app import ComposeResult from textual.screen import Screen from models.agent import Agent -from widgets.OTP_generate import OTPGenerator +from TUI.OTP_generate import OTPGenerator class OTPWorkflowScreen(Screen): diff --git a/widgets/policyselector.py b/TUI/policyselector.py similarity index 99% rename from widgets/policyselector.py rename to TUI/policyselector.py index fb34218..d6e4bdf 100644 --- a/widgets/policyselector.py +++ b/TUI/policyselector.py @@ -14,7 +14,7 @@ import pandas as pd from textual.containers import Horizontal, Vertical from textual.message import Message from textual.widget import Widget -from textual.widgets import Button, DataTable, Footer, Header, Static, TextArea +from textual.widgets import Button, DataTable, Static, TextArea from models.policy import Policy @@ -93,7 +93,6 @@ class PolicySelector(Widget): - Policy table displaying available policies - Back buttons for navigation """ - yield Header(show_clock=True, icon="βš™") title_text = Static( "🎯 Select Target Policy", id="policy_selector_title", @@ -164,8 +163,6 @@ class PolicySelector(Widget): policy_table.styles.margin = (1, 0, 1, 0) yield policy_table - yield Footer() - def on_mount(self) -> None: """ Initialize the policy table when the widget is mounted. diff --git a/screens/policyselectorscreen.py b/TUI/policyselectorscreen.py similarity index 98% rename from screens/policyselectorscreen.py rename to TUI/policyselectorscreen.py index 5937860..07ec2ee 100644 --- a/screens/policyselectorscreen.py +++ b/TUI/policyselectorscreen.py @@ -25,7 +25,7 @@ import logging from textual.app import ComposeResult from textual.screen import Screen -from widgets.policyselector import PolicySelector +from TUI.policyselector import PolicySelector logger = logging.getLogger(__name__) diff --git a/widgets/policytreewidget.py b/TUI/policytreewidget.py similarity index 100% rename from widgets/policytreewidget.py rename to TUI/policytreewidget.py diff --git a/TUI/quietagentworkflowscreen.py b/TUI/quietagentworkflowscreen.py new file mode 100644 index 0000000..552f214 --- /dev/null +++ b/TUI/quietagentworkflowscreen.py @@ -0,0 +1,848 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +""" +Quiet Agent Workflow Screen Module + +Provides a TUI workflow for identifying quiet agents and moving them to target policies. +This screen replaces the legacy quietAgent.py with a comprehensive TUI interface that: +1. Allows selection of an initial policy to analyze +2. Categorizes devices into "Enforce Ready" and "Non-Enforce Ready" based on activity +3. Allows users to select target policies for each category +4. Uses the API to move devices to their target policies +""" + +import datetime +import logging +import os +from typing import List, Optional + +import pandas as pd +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.reactive import reactive +from textual.screen import Screen +from textual.widgets import Button, DataTable, Footer, Header, Static + +from models.policy import Policy +from services.API import AirlockAPIWrapper +from services.policyhandler import getPolicyInfo +from TUI.policyselector import PolicySelector +from utils.configmanager import load_env + +logger = logging.getLogger(__name__) + + +class QuietAgentWorkflowScreen(Screen): + """ + A Textual screen for the Quiet Agent analysis and migration workflow. + + This screen provides a multi-step workflow: + 1. Select initial policy to analyze + 2. View categorized agents (enforce ready vs. non-enforce ready) + 3. Select target policies for each category + 4. Execute agent migrations + + Attributes: + api (AirlockAPIWrapper): API wrapper for Airlock operations + policies (List[Policy]): List of all available policies + selected_policy (Optional[Policy]): The initially selected policy to analyze + history_days (int): Number of days of history to pull (default: 150) + quiet_days (int): Number of days without execution to be considered quiet (default: 45) + agents_df (Optional[pd.DataFrame]): DataFrame of all agents with analysis results + enforce_ready_df (Optional[pd.DataFrame]): DataFrame of agents ready for enforcement + non_enforce_ready_df (Optional[pd.DataFrame]): DataFrame of agents not ready for enforcement + workflow_stage (str): Current stage of the workflow + """ + + BINDINGS = [ + ("escape", "go_back", "Back"), + ] + + workflow_stage = reactive("select_policy") # Tracks current workflow stage + + def __init__(self, api: AirlockAPIWrapper, policies: List[Policy]): + """ + Initialize the QuietAgentWorkflowScreen. + + Args: + api (AirlockAPIWrapper): API wrapper for Airlock operations + policies (List[Policy]): List of all available policies + """ + super().__init__() + self.api = api + self.policies = policies + self.selected_policy: Optional[Policy] = None + self.history_days = 150 # Fixed as per requirements + self.quiet_days = 45 # Default value + self.agents_df: Optional[pd.DataFrame] = None + self.enforce_ready_df: Optional[pd.DataFrame] = None + self.non_enforce_ready_df: Optional[pd.DataFrame] = None + self.enforce_ready_target_policy: Optional[Policy] = None + self.non_enforce_ready_target_policy: Optional[Policy] = None + + def compose(self) -> ComposeResult: + """Build the UI layout for the workflow screen.""" + # Include Header and Footer like other standalone screens + yield Header(show_clock=True, icon="βš™") + + # Title area + title = Static("πŸ”’ Quiet Agent Workflow", id="workflow_title") + title.styles.margin = (0, 0, 0, 1) + yield title + + # Status area + status = Static("Step 1: Select Policy to Analyze", id="workflow_status") + status.styles.margin = (0, 0, 1, 1) + yield status + + # Content area - dynamically populated based on workflow stage + yield Vertical(id="content_area") + + yield Footer() + + def on_mount(self) -> None: + """Initialize the screen when mounted.""" + # Show initial policy selection + self._show_policy_selection() + + def watch_workflow_stage(self, old_value: str, new_value: str) -> None: + """React to workflow stage changes.""" + logger.debug(f"Workflow stage changed from {old_value} to {new_value}") + self._update_status_message() + + def _update_status_message(self) -> None: + """Update the status message based on current workflow stage.""" + status_widget = self.query_one("#workflow_status", Static) + + stage_messages = { + "select_policy": "Step 1: Select Policy to Analyze", + "select_quiet_days": "Step 2: Select Quiet Time Period", + "analyzing": "πŸ“Š Analyzing agent activity...", + "view_results": "Step 3: Review Categorized Agents", + "select_enforce_target": "Step 4: Select Target Policy for Enforce Ready Agents", + "select_non_enforce_target": "Step 5: Select Target Policy for Non-Enforce Ready Agents", + "confirm_migration": "Step 6: Confirm and Execute Migration", + "executing": "⏳ Executing agent migrations...", + "complete": "βœ… Migration Complete", + } + + status_widget.update(stage_messages.get(self.workflow_stage, "Unknown Stage")) + + def _show_policy_selection(self) -> None: + """Show the initial policy selection screen.""" + self.workflow_stage = "select_policy" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Create policy selector widget + policy_selector = PolicySelector(self.policies) + content.mount(policy_selector) + + def on_policy_selector_policy_selected( + self, message: PolicySelector.PolicySelected + ) -> None: + """Handle policy selection from PolicySelector widget.""" + # Handle based on current workflow stage + if self.workflow_stage == "select_policy": + # Initial policy selection for analysis + self.selected_policy = message.policy + logger.info(f"Selected policy for analysis: {self.selected_policy.name}") + self._show_quiet_days_selection() + elif self.workflow_stage == "select_enforce_target": + # Target policy selection for enforce ready agents + self.enforce_ready_target_policy = message.policy + logger.info( + f"Selected target policy for enforce ready: {message.policy.name}" + ) + self._show_non_enforce_target_selection() + elif self.workflow_stage == "select_non_enforce_target": + # Target policy selection for non-enforce ready agents + self.non_enforce_ready_target_policy = message.policy + logger.info( + f"Selected target policy for non-enforce ready: {message.policy.name}" + ) + self._show_migration_confirmation() + + def _show_quiet_days_selection(self) -> None: + """Show the quiet days selection screen.""" + self.workflow_stage = "select_quiet_days" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Create info text + info_widget = Static( + f"Policy Selected: {self.selected_policy.name}\n\n" + f"History Period: {self.history_days} days\n\n" + "Select quiet time period (days without untrusted execution):", + id="quiet_days_info", + ) + info_widget.styles.margin = (0, 0, 2, 0) + content.mount(info_widget) + + # Create button container and mount it first + button_container = Vertical(id="quiet_days_buttons") + button_container.styles.height = "auto" + content.mount(button_container) + + # Now add buttons to the mounted container + for days in [15, 30, 45, 60]: + btn = Button( + f"{days} days {'(Default)' if days == 45 else ''}", + id=f"quiet_days_{days}", + classes="quiet_day_btn", + ) + btn.styles.width = "100%" + btn.styles.margin = (0, 0, 1, 0) + button_container.mount(btn) + + back_btn = Button("← Back", id="back_to_policy_selection") + back_btn.styles.width = "100%" + back_btn.styles.margin = (2, 0, 0, 0) + button_container.mount(back_btn) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button press events.""" + button_id = event.button.id + + # Quiet days selection buttons + if button_id and button_id.startswith("quiet_days_"): + days = int(button_id.split("_")[-1]) + self.quiet_days = days + logger.info(f"Selected quiet days: {days}") + self._start_analysis() + return + + # Navigation buttons + if button_id == "back_to_policy_selection": + self._show_policy_selection() + return + + if button_id == "back_to_results": + self._show_results() + return + + if button_id == "select_enforce_target_btn": + self._show_enforce_target_selection() + return + + if button_id == "select_non_enforce_target_btn": + self._show_non_enforce_target_selection() + return + + if button_id == "skip_enforce_target_btn": + # Skip enforce ready target selection + self.enforce_ready_target_policy = None + self._show_non_enforce_target_selection() + return + + if button_id == "skip_non_enforce_target_btn": + # Skip non-enforce ready target selection + self.non_enforce_ready_target_policy = None + self._show_migration_confirmation() + return + + if button_id == "confirm_migration_btn": + self._execute_migration() + return + + if button_id == "cancel_migration_btn": + self._show_results() + return + + if button_id == "export_results_btn": + self._export_results() + return + + if button_id == "start_over_btn": + self._show_policy_selection() + return + + def _start_analysis(self) -> None: + """Start the agent activity analysis.""" + self.workflow_stage = "analyzing" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Show analyzing message with detailed steps + analyzing_msg = Static( + f"πŸ“Š Analyzing Agent Activity\n" + f"{'=' * 50}\n\n" + f"Policy: {self.selected_policy.name}\n" + f"History Period: {self.history_days} days\n" + f"Quiet Threshold: {self.quiet_days} days\n\n" + f"Progress:\n" + f"⏳ Step 1/4: Fetching agents from policy...\n" + f"⏱️ Step 2/4: Pulling execution history (this may take a moment)...\n" + f"⏱️ Step 3/4: Analyzing activity patterns...\n" + f"⏱️ Step 4/4: Categorizing agents...\n\n" + f"Please wait - this operation cannot be cancelled.", + id="analyzing_message", + ) + analyzing_msg.styles.margin = (2, 1) + content.mount(analyzing_msg) + + # Show notification + self.app.notify( + "Starting analysis - this may take several minutes for large policies", + severity="information", + timeout=5, + ) + + # Perform the analysis asynchronously + self.call_later(self._perform_analysis) + + def _perform_analysis(self) -> None: + """Perform the actual agent activity analysis.""" + try: + # Update status: Fetching agents + self._update_analysis_status("Step 1/4: Fetching agents from policy...") + + # Get agents in the selected policy + agents = self.api.agents_find_by_group(self.selected_policy.groupid) + + if agents.empty: + self.app.notify( + f"No agents found in policy: {self.selected_policy.name}", + severity="warning", + timeout=5, + ) + self._show_policy_selection() + return + + agent_count = len(agents) + self.app.notify( + f"Found {agent_count} agents - fetching execution history...", + severity="information", + timeout=3, + ) + + # Update status: Pulling execution history + self._update_analysis_status( + f"Step 2/4: Pulling execution history for {agent_count} agents...\n" + f"(This may take several minutes - progress shown in terminal)" + ) + + # Get execution history (this shows progress bars in terminal via airlock_libs) + policy_exec_history = getPolicyInfo( + self.api, self.selected_policy, [1, 2, 6, 7], self.history_days + ) + + # Update status: Analyzing patterns + self._update_analysis_status("Step 3/4: Analyzing activity patterns...") + self.app.notify( + "History retrieved - analyzing patterns...", + severity="information", + timeout=2, + ) + + if policy_exec_history.empty: + logger.info( + "No execution history found for the selected policy and time range." + ) + # All agents are quiet (no executions) + agents["execution_count"] = 0 + agents["days_since"] = None + agents["required_quiet"] = self.quiet_days + agents["enforce_ready"] = True + else: + # Convert datetime column + policy_exec_history["datetime"] = pd.to_datetime( + policy_exec_history["datetime"], + format="%Y-%m-%dT%H:%M:%SZ", + utc=True, + ) + + # Calculate days ago + now = datetime.datetime.now(datetime.timezone.utc) + policy_exec_history["days_ago"] = policy_exec_history["datetime"].apply( + lambda dt: (now - dt).days + ) + + # Count total executions per hostname + hostname_counts = policy_exec_history["hostname"].value_counts() + agents["execution_count"] = ( + agents["hostname"].map(hostname_counts).fillna(0).astype(int) + ) + + # Find most recent execution per hostname + most_recent_exec = policy_exec_history.sort_values( + by="days_ago" + ).drop_duplicates(subset="hostname", keep="first") + + # Map most recent execution age to agents + agents["days_since"] = agents["hostname"].map( + most_recent_exec.set_index("hostname")["days_ago"] + ) + + # Check for enforcement readiness + agents["required_quiet"] = self.quiet_days + agents["enforce_ready"] = agents["days_since"].apply( + lambda x: True if pd.isna(x) or x > self.quiet_days else False + ) + + # Update status: Categorizing + self._update_analysis_status("Step 4/4: Categorizing agents...") + + # Sort agents + agents = agents.sort_values( + by=["execution_count", "hostname"], ascending=[True, True] + ) + + # Store the results + self.agents_df = agents + + # Categorize agents into DataFrames + self.enforce_ready_df = agents[agents["enforce_ready"] == True].copy() + self.non_enforce_ready_df = agents[agents["enforce_ready"] == False].copy() + + logger.info( + f"Analysis complete: {len(self.enforce_ready_df)} enforce ready, " + f"{len(self.non_enforce_ready_df)} non-enforce ready" + ) + + self.app.notify( + f"Analysis complete! Found {len(self.enforce_ready_df)} enforce ready, " + f"{len(self.non_enforce_ready_df)} not ready", + severity="success", + timeout=5, + ) + + # Show results + self._show_results() + + except Exception as e: + logger.error(f"Error during analysis: {e}", exc_info=True) + self.app.notify(f"Analysis failed: {str(e)}", severity="error", timeout=5) + self._show_policy_selection() + + def _update_analysis_status(self, status_text: str) -> None: + """Update the analysis status message.""" + try: + analyzing_msg = self.query_one("#analyzing_message", Static) + + # Build updated message + updated_text = ( + f"πŸ“Š Analyzing Agent Activity\n" + f"{'=' * 50}\n\n" + f"Policy: {self.selected_policy.name}\n" + f"History Period: {self.history_days} days\n" + f"Quiet Threshold: {self.quiet_days} days\n\n" + f"Progress:\n" + f"βœ… {status_text}\n\n" + f"Please wait - this operation cannot be cancelled." + ) + + analyzing_msg.update(updated_text) + except Exception as e: + logger.debug(f"Could not update analysis status: {e}") + + def _show_results(self) -> None: + """Show the categorized results.""" + self.workflow_stage = "view_results" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Create results display container and mount it first + results_container = Vertical(id="results_container") + results_container.styles.height = "auto" + results_container.styles.margin = (1, 1) + content.mount(results_container) + + # Summary statistics + total_agents = len(self.enforce_ready_df) + len(self.non_enforce_ready_df) + ready_count = len(self.enforce_ready_df) + not_ready_count = len(self.non_enforce_ready_df) + ready_percentage = (ready_count / total_agents * 100) if total_agents > 0 else 0 + + summary = Static( + f"Analysis Results for: {self.selected_policy.name}\n\n" + f"πŸ“Š Total Agents: {total_agents}\n" + f"βœ… Enforce Ready: {ready_count} ({ready_percentage:.1f}%)\n" + f"❌ Not Ready: {not_ready_count} ({100 - ready_percentage:.1f}%)\n\n" + f"Quiet Threshold: {self.quiet_days} days\n" + f"History Period: {self.history_days} days", + id="results_summary", + ) + summary.styles.margin = (0, 0, 2, 0) + results_container.mount(summary) + + # Action buttons + button_container = Horizontal(id="results_buttons") + button_container.styles.height = "auto" + results_container.mount(button_container) + + if ready_count > 0: + enforce_btn = Button( + f"Select Target for Enforce Ready ({ready_count})", + id="select_enforce_target_btn", + ) + enforce_btn.styles.margin = (0, 1, 1, 0) + button_container.mount(enforce_btn) + + if not_ready_count > 0: + non_enforce_btn = Button( + f"Select Target for Non-Enforce Ready ({not_ready_count})", + id="select_non_enforce_target_btn", + ) + non_enforce_btn.styles.margin = (0, 1, 1, 0) + button_container.mount(non_enforce_btn) + + export_btn = Button("πŸ’Ύ Export Results", id="export_results_btn") + export_btn.styles.margin = (0, 1, 1, 0) + button_container.mount(export_btn) + + start_over_btn = Button("πŸ”„ Start Over", id="start_over_btn") + start_over_btn.styles.margin = (0, 0, 1, 0) + button_container.mount(start_over_btn) + + # Tables showing agents + tables_container = Horizontal() + tables_container.styles.height = "1fr" + results_container.mount(tables_container) + + # Enforce Ready table + if ready_count > 0: + enforce_col = Vertical() + enforce_col.styles.width = "1fr" + enforce_col.styles.margin = (1, 1, 0, 0) + tables_container.mount(enforce_col) + + enforce_label = Static("βœ… Enforce Ready Agents") + enforce_label.styles.margin = (0, 0, 1, 0) + enforce_col.mount(enforce_label) + + enforce_table = DataTable(id="enforce_ready_table") + enforce_table.styles.height = "1fr" + enforce_table.add_columns("Hostname", "Last Exec (days)") + + # Display first 50 agents + for idx, row in self.enforce_ready_df.head(50).iterrows(): + days_since = row["days_since"] + days_str = f"{int(days_since)}" if not pd.isna(days_since) else "Never" + enforce_table.add_row(row["hostname"], days_str) + + if len(self.enforce_ready_df) > 50: + enforce_table.add_row( + f"... and {len(self.enforce_ready_df) - 50} more", "" + ) + + enforce_col.mount(enforce_table) + + # Non-Enforce Ready table + if not_ready_count > 0: + non_enforce_col = Vertical() + non_enforce_col.styles.width = "1fr" + non_enforce_col.styles.margin = (1, 0, 0, 1) + tables_container.mount(non_enforce_col) + + non_enforce_label = Static("❌ Non-Enforce Ready Agents") + non_enforce_label.styles.margin = (0, 0, 1, 0) + non_enforce_col.mount(non_enforce_label) + + non_enforce_table = DataTable(id="non_enforce_ready_table") + non_enforce_table.styles.height = "1fr" + non_enforce_table.add_columns("Hostname", "Last Exec (days)") + + # Display first 50 agents + for idx, row in self.non_enforce_ready_df.head(50).iterrows(): + days_since = row["days_since"] + days_str = f"{int(days_since)}" if not pd.isna(days_since) else "N/A" + non_enforce_table.add_row(row["hostname"], days_str) + + if len(self.non_enforce_ready_df) > 50: + non_enforce_table.add_row( + f"... and {len(self.non_enforce_ready_df) - 50} more", "" + ) + + non_enforce_col.mount(non_enforce_table) + + def _show_enforce_target_selection(self) -> None: + """Show policy selection for enforce ready agents.""" + self.workflow_stage = "select_enforce_target" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Info message + info = Static( + f"Select target policy for {len(self.enforce_ready_df)} Enforce Ready agents\n" + f"Source Policy: {self.selected_policy.name}", + id="enforce_target_info", + ) + info.styles.margin = (0, 0, 2, 0) + content.mount(info) + + # Policy selector + policy_selector = PolicySelector(self.policies) + content.mount(policy_selector) + + # Skip button + skip_btn = Button("⭕️ Skip - No Migration", id="skip_enforce_target_btn") + skip_btn.styles.width = "50%" + skip_btn.styles.margin = (2, 0, 0, 0) + content.mount(skip_btn) + + def _show_non_enforce_target_selection(self) -> None: + """Show policy selection for non-enforce ready agents.""" + self.workflow_stage = "select_non_enforce_target" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Info message + info = Static( + f"Select target policy for {len(self.non_enforce_ready_df)} Non-Enforce Ready agents\n" + f"Source Policy: {self.selected_policy.name}", + id="non_enforce_target_info", + ) + info.styles.margin = (0, 0, 2, 0) + content.mount(info) + + # Policy selector + policy_selector = PolicySelector(self.policies) + content.mount(policy_selector) + + # Skip button + skip_btn = Button("⭕️ Skip - No Migration", id="skip_non_enforce_target_btn") + skip_btn.styles.width = "50%" + skip_btn.styles.margin = (2, 0, 0, 0) + content.mount(skip_btn) + + def _show_migration_confirmation(self) -> None: + """Show migration confirmation screen.""" + self.workflow_stage = "confirm_migration" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Build confirmation message + confirmation_lines = [ + "πŸ” Migration Summary\n", + f"Source Policy: {self.selected_policy.name}\n", + ] + + if self.enforce_ready_target_policy: + confirmation_lines.append( + f"\nβœ… Enforce Ready Migration:\n" + f" β€’ Agents: {len(self.enforce_ready_df)}\n" + f" β€’ Target: {self.enforce_ready_target_policy.name}\n" + ) + + if self.non_enforce_ready_target_policy: + confirmation_lines.append( + f"\n❌ Non-Enforce Ready Migration:\n" + f" β€’ Agents: {len(self.non_enforce_ready_df)}\n" + f" β€’ Target: {self.non_enforce_ready_target_policy.name}\n" + ) + + if ( + not self.enforce_ready_target_policy + and not self.non_enforce_ready_target_policy + ): + confirmation_lines.append("\n⚠️ No migrations will be performed.") + + confirmation = Static("".join(confirmation_lines), id="migration_confirmation") + confirmation.styles.margin = (1, 1, 2, 1) + content.mount(confirmation) + + # Action buttons - mount container first, then add buttons + button_container = Horizontal(id="confirmation_buttons") + button_container.styles.height = "auto" + button_container.styles.margin = (1, 1) + content.mount(button_container) + + if self.enforce_ready_target_policy or self.non_enforce_ready_target_policy: + confirm_btn = Button("βœ… Confirm Migration", id="confirm_migration_btn") + confirm_btn.styles.margin = (0, 1, 0, 0) + button_container.mount(confirm_btn) + + cancel_btn = Button("❌ Cancel", id="cancel_migration_btn") + button_container.mount(cancel_btn) + + def _execute_migration(self) -> None: + """Execute the agent migrations.""" + self.workflow_stage = "executing" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Show executing message + executing_msg = Static( + "⏳ Executing agent migrations...\nPlease wait...", + id="executing_message", + ) + executing_msg.styles.margin = (2, 1) + content.mount(executing_msg) + + # Perform migrations asynchronously + self.call_later(self._perform_migrations) + + def _perform_migrations(self) -> None: + """Perform the actual agent migrations.""" + successful_migrations = [] + failed_migrations = [] + + try: + # Migrate enforce ready agents + if self.enforce_ready_target_policy: + for idx, row in self.enforce_ready_df.iterrows(): + try: + result = self.api.agent_move( + row["agentid"], self.enforce_ready_target_policy.groupid + ) + successful_migrations.append( + (row["hostname"], self.enforce_ready_target_policy.name) + ) + logger.debug( + f"Moved {row['hostname']} to {self.enforce_ready_target_policy.name}" + ) + except Exception as e: + failed_migrations.append((row["hostname"], str(e))) + logger.error(f"Failed to move {row['hostname']}: {e}") + + # Migrate non-enforce ready agents + if self.non_enforce_ready_target_policy: + for idx, row in self.non_enforce_ready_df.iterrows(): + try: + result = self.api.agent_move( + row["agentid"], self.non_enforce_ready_target_policy.groupid + ) + successful_migrations.append( + (row["hostname"], self.non_enforce_ready_target_policy.name) + ) + logger.debug( + f"Moved {row['hostname']} to {self.non_enforce_ready_target_policy.name}" + ) + except Exception as e: + failed_migrations.append((row["hostname"], str(e))) + logger.error(f"Failed to move {row['hostname']}: {e}") + + # Show completion results + self._show_completion_results(successful_migrations, failed_migrations) + + except Exception as e: + logger.error(f"Error during migration execution: {e}", exc_info=True) + self.app.notify(f"Migration failed: {str(e)}", severity="error", timeout=5) + self._show_results() + + def _show_completion_results( + self, successful: List[tuple], failed: List[tuple] + ) -> None: + """Show migration completion results.""" + self.workflow_stage = "complete" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Results summary + total_attempted = len(successful) + len(failed) + success_rate = ( + (len(successful) / total_attempted * 100) if total_attempted > 0 else 0 + ) + + results = Static( + f"βœ… Migration Complete\n\n" + f"Total Agents Migrated: {len(successful)}\n" + f"Failed Migrations: {len(failed)}\n" + f"Success Rate: {success_rate:.1f}%", + id="completion_summary", + ) + results.styles.margin = (1, 1, 2, 1) + content.mount(results) + + # Details tables + if successful: + success_container = Vertical() + success_container.styles.margin = (0, 1) + content.mount(success_container) + + success_label = Static("βœ… Successful Migrations") + success_label.styles.margin = (0, 0, 1, 0) + success_container.mount(success_label) + + success_table = DataTable(id="success_table") + success_table.styles.height = "auto" + success_table.add_columns("Hostname", "Target Policy") + + for hostname, target_policy in successful[:25]: # Show first 25 + success_table.add_row(hostname, target_policy) + + if len(successful) > 25: + success_table.add_row(f"... and {len(successful) - 25} more", "") + + success_container.mount(success_table) + + if failed: + failed_container = Vertical() + failed_container.styles.margin = (2, 1, 0, 1) + content.mount(failed_container) + + failed_label = Static("❌ Failed Migrations") + failed_label.styles.margin = (0, 0, 1, 0) + failed_container.mount(failed_label) + + failed_table = DataTable(id="failed_table") + failed_table.styles.height = "auto" + failed_table.add_columns("Hostname", "Error") + + for hostname, error in failed[:25]: # Show first 25 + failed_table.add_row(hostname, error[:50]) # Truncate error + + if len(failed) > 25: + failed_table.add_row(f"... and {len(failed) - 25} more", "") + + failed_container.mount(failed_table) + + # Action button + done_btn = Button("βœ” Done", id="start_over_btn") + done_btn.styles.width = "50%" + done_btn.styles.margin = (2, 0, 0, 0) + content.mount(done_btn) + + def _export_results(self) -> None: + """Export analysis results to CSV.""" + try: + working_dir = load_env("WORKING_DIR") or os.getcwd() + filename = os.path.join( + working_dir, + f"{self.selected_policy.name}_quiet_analysis_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", + ) + + self.agents_df.to_csv(filename, index=False) + logger.info(f"Exported results to {filename}") + self.app.notify( + f"Results exported to:\n{filename}", + severity="information", + timeout=5, + ) + + except Exception as e: + logger.error(f"Failed to export results: {e}") + self.app.notify(f"Export failed: {str(e)}", severity="error", timeout=5) + + def action_go_back(self) -> None: + """Handle back/escape action.""" + # Depending on stage, go back to previous stage or exit + if self.workflow_stage in ["select_policy", "view_results", "complete"]: + self.app.pop_screen() + elif self.workflow_stage == "select_quiet_days": + self._show_policy_selection() + elif self.workflow_stage == "select_enforce_target": + self._show_results() + elif self.workflow_stage == "select_non_enforce_target": + if self.enforce_ready_target_policy: + self._show_enforce_target_selection() + else: + self._show_results() + elif self.workflow_stage == "confirm_migration": + self._show_non_enforce_target_selection() + else: + self.app.pop_screen() diff --git a/widgets/resultsdisplay.py b/TUI/resultsdisplay.py similarity index 100% rename from widgets/resultsdisplay.py rename to TUI/resultsdisplay.py diff --git a/themes/amber_terminal_theme.py b/TUI/theme_amber_terminal.py similarity index 93% rename from themes/amber_terminal_theme.py rename to TUI/theme_amber_terminal.py index 169c325..3662892 100644 --- a/themes/amber_terminal_theme.py +++ b/TUI/theme_amber_terminal.py @@ -12,7 +12,7 @@ def get_amber_terminal_theme(): success=Color.parse("#ffb733"), warning=Color.parse("#ffff66"), error=Color.parse("#ff3300"), - surface=Color.parse("#3a1f00"), # brighter brown for blending + surface=Color.parse("#49331a"), # brighter brown for blending ) diff --git a/themes/retro_terminal_theme.py b/TUI/theme_retro_terminal.py similarity index 100% rename from themes/retro_terminal_theme.py rename to TUI/theme_retro_terminal.py diff --git a/widgets/themeselector.py b/TUI/themeselector.py similarity index 100% rename from widgets/themeselector.py rename to TUI/themeselector.py diff --git a/flows/otp.py b/flows/otp.py index 720aab3..ee07c98 100644 --- a/flows/otp.py +++ b/flows/otp.py @@ -14,98 +14,18 @@ # along with this program. If not, see . -from datetime import datetime import logging -import os import pandas as pd from services.agenthandler import selectAgents from services.API import AirlockAPIWrapper -from utils.configmanager import load_env from utils.selector import Selector -from utils.utils import colorText, get_sanitized_input +from utils.utils import get_sanitized_input logger = logging.getLogger(__name__) -def otp_activities_by_agent(api: AirlockAPIWrapper): - activeagents = api.otp_find_active() - awaitingagents = api.otp_find_awaiting() - enforcedagents = api.otp_find_enforced() - revokedagents = api.otp_find_revoked() - - # Add a 'status' column to each DataFrame - activeagents["status"] = "active" - awaitingagents["status"] = "awaiting" - enforcedagents["status"] = "enforced" - revokedagents["status"] = "revoked" - - # Combine all into one DataFrame - combined_agents = pd.concat( - [activeagents, awaitingagents, enforcedagents, revokedagents], ignore_index=True - ) - combined_agents = combined_agents.sort_values(by="otpid", ascending=False) - - # Optionally, select specific hosts - user_input = ( - get_sanitized_input("\nWould you like to search for a specific device? (y/n): ") - .strip() - .lower() - ) - if user_input == "y": - agentnames = [] - agents = selectAgents(api) - for agent in agents: - agentnames.append(agent.hostname) - - combined_agents = combined_agents[combined_agents["hostname"].isin(agentnames)] - - # Present and select rows - selected_rows = Selector.select_dataframe_with_mode( - combined_agents, - columns=["otpid", "hostname", "status", "purpose", "granted"], - header="OTP Sessions", - ) - combined_df = pd.DataFrame() - - for row in selected_rows: - otpid = row["otpid"] - hostname = row["hostname"] - result = api.otp_get_activities(otpid) - result["hostname"] = hostname - if not result.empty: - logger.info(f"Activities for {hostname} (otpid: {otpid}):\n{result}") - combined_df = pd.concat([combined_df, result], ignore_index=True) - else: - logger.info(f"No activities found for {hostname} (otpid: {otpid})") - - user_input = ( - get_sanitized_input( - "\nWould you like to export the results to a CSV file? (y/n): " - ) - .strip() - .lower() - ) - if user_input == "y": - working_dir = load_env("WORKING_DIR") - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - filename = f"otp_activities_{timestamp}.csv" - file_path = os.path.join(str(working_dir), filename) - - combined_df.to_csv(file_path, index=False) - logging.info(f"Exported Data to {file_path}") - - print( - colorText( - f"\nβœ… OTP Activity exported to: {working_dir}\\{filename}", - "green", - ) - ) - else: - logging.debug("User declined to export the DataFrame.") - - def otp_revoke(api: AirlockAPIWrapper): activeagents = api.otp_find_active() diff --git a/services/API.py b/services/API.py index 34ce788..7fab0ac 100644 --- a/services/API.py +++ b/services/API.py @@ -253,6 +253,19 @@ class AirlockAPIWrapper: } return self._post("/v1/group/settings/script_custom", payload) + def policy_set_upgradetarget( + self, + groupid: str, + windows: str, + macos: str, + ) -> dict: + payload = { + "groupid": groupid, + "windows": windows, + "macos": macos, + } + return self._post("/v1/group/settings/selfupgrade/target", payload) + # Execution History def history_logging( self, type: List[str], checkpoint: str, policy: List[str] From 32e296238bf359134d8a0ead71915ded66daa38e Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Mon, 17 Nov 2025 10:50:02 -0500 Subject: [PATCH 20/29] Closes #27 Implemented Opt-in/Opt-Out logic --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/services.rs | 68 +++++++++++++++++++++++++++--------- requirements.txt | 2 +- 5 files changed, 55 insertions(+), 21 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index 5378795..d087dca 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "3.0.0" +version = "3.1.0" dependencies = [ "chrono", "indicatif", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 782fd33..57b50bf 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "3.0.0" +version = "3.1.0" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index e6e5151..995ef7f 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "3.0.0" +version = "3.1.0" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index bcde4ce..5d14698 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -3,6 +3,7 @@ use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; use mongodb::bson::oid::ObjectId; use opentelemetry::global::shutdown_tracer_provider; use opentelemetry::sdk::Resource; +use opentelemetry::trace::noop::NoopTracerProvider; use opentelemetry::trace::{Status, TraceContextExt, TraceError}; use opentelemetry::{Context, KeyValue, sdk::trace as sdktrace, trace::Tracer}; use opentelemetry::{Key, global}; @@ -23,7 +24,12 @@ use std::{ path::PathBuf, str::FromStr, }; -use tracing_subscriber::prelude::*; + +#[derive(Deserialize, Debug)] +struct TelemetryConfig { + TELEMETRY: bool, + TELEM_URL: Option, +} #[derive(Debug, Deserialize, Serialize)] struct ApiResponse { @@ -140,7 +146,6 @@ pub fn pull_policy_exec_histories( let span = cx.span(); span.set_attribute(Key::new("Days").string(days.to_string().to_string())); loop { - tracing::info!("Starting Airlock Data Retrieval"); f.seek(SeekFrom::Start(0)).unwrap(); let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| { let results: ApiResponse = history_logging( @@ -329,19 +334,48 @@ fn skipback(days: i64) -> ObjectId { ObjectId::parse_str(&objectid_hex).expect("Invalid ObjectId hex") } -fn init_tracer() -> Result { - opentelemetry_otlp::new_pipeline() - .tracing() - .with_exporter( - opentelemetry_otlp::new_exporter() - .tonic() - .with_endpoint("https://signoz.racooncity.org"), - ) - .with_trace_config( - sdktrace::config().with_resource(Resource::new(vec![KeyValue::new( - "service.name", - "LoxideLibs", - )])), - ) - .install_simple() +fn load_telemetry_config() -> TelemetryConfig { + let cfg_path = get_base_directory().join("config\\user_config.json"); + if !cfg_path.exists() { + return TelemetryConfig { + TELEMETRY: false, + TELEM_URL: None, + }; + } + match fs::read_to_string(&cfg_path) { + Ok(contents) => { + serde_json::from_str::(&contents).unwrap_or(TelemetryConfig { + TELEMETRY: false, + TELEM_URL: None, + }) + } + Err(_) => TelemetryConfig { + TELEMETRY: false, + TELEM_URL: None, + }, + } +} + +fn init_tracer() -> Result, TraceError> { + let cfg = load_telemetry_config(); + if !cfg.TELEMETRY { + global::set_tracer_provider(NoopTracerProvider::new()); + println!("Telemetry Disabled by Config"); + return Ok(None); + } + let endpoint = cfg.TELEM_URL.unwrap_or_default(); + let tracer = + opentelemetry_otlp::new_pipeline() + .tracing() + .with_exporter( + opentelemetry_otlp::new_exporter() + .tonic() + .with_endpoint(endpoint), + ) + .with_trace_config(sdktrace::config().with_resource(Resource::new(vec![ + KeyValue::new("service.name", "LoxideLibs"), + ]))) + .install_simple() + .unwrap(); + Ok(Some(tracer)) } diff --git a/requirements.txt b/requirements.txt index 1468119..fe6d28a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==3.0.0 \ No newline at end of file +airlock_libs==3.1.0 \ No newline at end of file From b198362ac8cffc9785766df88b18e678b4244ff5 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Mon, 17 Nov 2025 11:06:37 -0500 Subject: [PATCH 21/29] Removed println indicator if Telemetry is enabled/disabled --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/services.rs | 1 - requirements.txt | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index d087dca..e9ed8c8 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "3.1.0" +version = "3.1.1" dependencies = [ "chrono", "indicatif", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 57b50bf..5d5ab76 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "3.1.0" +version = "3.1.1" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 995ef7f..044f251 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "3.1.0" +version = "3.1.1" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index 5d14698..c0f7d21 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -360,7 +360,6 @@ fn init_tracer() -> Result, TraceError> { let cfg = load_telemetry_config(); if !cfg.TELEMETRY { global::set_tracer_provider(NoopTracerProvider::new()); - println!("Telemetry Disabled by Config"); return Ok(None); } let endpoint = cfg.TELEM_URL.unwrap_or_default(); diff --git a/requirements.txt b/requirements.txt index fe6d28a..794b24a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==3.1.0 \ No newline at end of file +airlock_libs==3.1.1 \ No newline at end of file From 89654d3a8c2ab94ef55e531809893e4c80079f40 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Mon, 17 Nov 2025 12:00:32 -0500 Subject: [PATCH 22/29] Config Refactor: unify config handling - Consolidated all system/user config logic into configmanager.py - Removed duplicate loaders from setup.py and TUI.py - Eliminated .env redundancy; now only stores WORKING_DIR - Clarified boundaries: system config immutable, user config mutable - Updated TUI to use save_user_config() - Removed all deprecated/legacy config functions and aliases --- TUI/TUI.py | 57 ++----- TUI/agentmoveoperations.py | 52 +++--- airlock_libs/airlock_libs.pyi | 2 +- default_system_config.json | 39 ++++- flows/localApproval.py | 51 +++--- flows/prepPolicy.py | 58 +++---- flows/quietAgent.py | 134 ---------------- models/execution.py | 22 +-- services/agenthandler.py | 32 ++-- services/policyhandler.py | 4 +- utils/configmanager.py | 289 +++++++++++++++++++++++++++++----- utils/setup.py | 109 ++++--------- utils/test.py | 0 13 files changed, 432 insertions(+), 417 deletions(-) delete mode 100644 flows/quietAgent.py delete mode 100644 utils/test.py diff --git a/TUI/TUI.py b/TUI/TUI.py index 990cde3..2c5d4fc 100644 --- a/TUI/TUI.py +++ b/TUI/TUI.py @@ -4,7 +4,6 @@ import sys from typing import Optional import dotenv -from dotenv import set_key from textual.app import App, ComposeResult from textual.containers import Vertical from textual.message import Message @@ -38,8 +37,8 @@ from TUI.resultsdisplay import ResultsDisplay from TUI.theme_amber_terminal import get_amber_terminal_theme from TUI.theme_retro_terminal import get_retro_terminal_theme from TUI.themeselector import ThemeSelector -from utils.configmanager import load_env -from utils.setup import get_base_directory, load_user_config +from utils.configmanager import get_user_value, load_env, save_user_config +from utils.setup import get_base_directory from utils.utils import open_directory dotenv.load_dotenv() @@ -58,47 +57,17 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- def _persist_user_theme(theme_name: str) -> None: """ - Store the chosen Textual theme in the user's config: - /config/user_config.json - and also mirror to /.env so load_env(...) sees it. + Store the chosen Textual theme in the user's config using the config manager. + No need to touch .env - config manager handles everything. """ base_dir = get_base_directory() config_dir = base_dir / "config" - user_config_path = config_dir / "user_config.json" - env_path = base_dir / ".env" - # ensure dirs / files exist similarly to setup() - config_dir.mkdir(parents=True, exist_ok=True) - if not user_config_path.exists(): - # minimal default like your load_user_config does - user_config_path.write_text( - '{"URL": "", "LOG_LEVEL": "INFO"}\n', encoding="utf-8" - ) - - # load existing user config - user_conf = load_user_config(config_dir) - user_conf["TEXTUAL_THEME"] = theme_name - - # write it back - user_config_path.write_text( - # pretty print so it stays human-readable - __import__("json").dumps(user_conf, indent=4), - encoding="utf-8", - ) - logger.debug("Updated user_config.json with TEXTUAL_THEME=%s", theme_name) - - # mirror to .env (like setup.write_config_to_env does) - env_path.parent.mkdir(parents=True, exist_ok=True) - if not env_path.exists(): - env_path.touch() try: - set_key(str(env_path), "TEXTUAL_THEME", theme_name) - except Exception as exc: # keep going even if .env write fails - logger.warning("Failed to mirror TEXTUAL_THEME to .env: %s", exc) - - # reload so load_env(...) sees the new value right now - dotenv.load_dotenv(dotenv_path=env_path, override=True) - logger.debug("Reloaded .env from %s", env_path) + save_user_config(config_dir, {"TEXTUAL_THEME": theme_name}) + logger.debug("Updated user config with TEXTUAL_THEME=%s", theme_name) + except Exception as exc: + logger.error("Failed to save TEXTUAL_THEME: %s", exc) # --------------------------------------------------------------------------- @@ -115,7 +84,7 @@ class MainMenuScreen(Screen): "move_agent_workflow_button", ), ("πŸ“Š - Review and appove OTP Activities", "otp_activities_button"), - ("πŸ”‡ - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"), + ("πŸ“‡ - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"), ], "policy": [ ("πŸ”’ - Prepare Policy For Enforcement", "policy_prep_button"), @@ -126,7 +95,7 @@ class MainMenuScreen(Screen): def __init__(self) -> None: super().__init__() - self.extras = load_env("EXTRAS") + self.extras = get_user_value("EXTRAS", str, "NOTTODAY") wd = load_env("WORKING_DIR") or os.getcwd() if not os.path.isdir(wd): wd = os.getcwd() @@ -380,10 +349,11 @@ class Loxide(App[Message]): BINDINGS = [ ("q", "quit", "Quit"), ("f", "open_fe", "Launch Explorer"), + ("r", "refresh", "Refresh"), ] def __init__(self, api: AirlockAPIWrapper): - self._textual_theme = load_env("TEXTUAL_THEME") or "nord" + self._textual_theme = get_user_value("TEXTUAL_THEME", str, "nord") super().__init__() self.api = api wd = load_env("WORKING_DIR") or os.getcwd() @@ -421,6 +391,9 @@ class Loxide(App[Message]): self.theme = self._textual_theme self.push_screen(MainMenuScreen()) + def action_refresh(self) -> None: + self.refresh_data() + def action_quit(self) -> None: global _PENDING_JOB _PENDING_JOB = None diff --git a/TUI/agentmoveoperations.py b/TUI/agentmoveoperations.py index d3615f0..26032a0 100644 --- a/TUI/agentmoveoperations.py +++ b/TUI/agentmoveoperations.py @@ -183,21 +183,21 @@ class AgentMoveOperations(Widget): f"Operation: {operation_name}", f"{'=' * 50}", "", - f"βœ… Successful ({len(successful)}):", + f"Òœ… Successful ({len(successful)}):", ] if successful: for agent, result in successful: - results_lines.append(f" βœ… {agent.hostname}") + results_lines.append(f" Òœ… {agent.hostname}") else: results_lines.append(" (none)") results_lines.append("") - results_lines.append(f"❌ Failed ({len(unsuccessful)}):") + results_lines.append(f"ҝŒ Failed ({len(unsuccessful)}):") if unsuccessful: for agent, error in unsuccessful: - results_lines.append(f" ❌ {agent.hostname}: {error}") + results_lines.append(f" ҝŒ {agent.hostname}: {error}") else: results_lines.append(" (none)") @@ -232,9 +232,9 @@ class AgentMoveOperations(Widget): - Operations panel: 1/3 width - Results area: Initially hidden, shown after operation completion """ - yield Header(show_clock=True, icon="βš™") + yield Header(show_clock=True, icon="Γ’Ε‘β„’") title_text = Static( - f"πŸ–₯️ Agent Operations - {len(self.agents)} device(s) selected", + f"ðŸ–Β₯️ Agent Operations - {len(self.agents)} device(s) selected", id="move_ops_title", ) title_text.styles.margin = (0, 0, 1, 0) @@ -269,32 +269,32 @@ class AgentMoveOperations(Widget): yield operations_label # Operation buttons - export_csv_btn = Button("πŸ“ˆ Export CSV", id="export_csv_btn") + export_csv_btn = Button("Γ°ΕΈβ€œΛ† Export CSV", id="export_csv_btn") export_csv_btn.styles.width = "100%" export_csv_btn.styles.margin = (0, 0, 1, 0) yield export_csv_btn local_approval_btn = Button( - "βœ”οΈ Local Approval Mode", id="local_approval_btn" + "Òœ”️ Local Approval Mode", id="local_approval_btn" ) local_approval_btn.styles.width = "100%" local_approval_btn.styles.margin = (0, 0, 1, 0) yield local_approval_btn - otp_gen_btn = Button("🎫 Generate One Time Passes", id="otp_gen_btn") + otp_gen_btn = Button("Γ°ΕΈΕ½Β« Generate One Time Passes", id="otp_gen_btn") otp_gen_btn.styles.width = "100%" otp_gen_btn.styles.margin = (0, 0, 1, 0) yield otp_gen_btn toggle_enforcement_btn = Button( - "πŸ”„ Toggle Audit/Enforcement", id="toggle_enforcement_btn" + "Γ°ΕΈβ€β€ž Toggle Audit/Enforcement", id="toggle_enforcement_btn" ) toggle_enforcement_btn.styles.width = "100%" toggle_enforcement_btn.styles.margin = (0, 0, 1, 0) yield toggle_enforcement_btn other_policy_btn = Button( - "πŸ”€ Move to Other Policy", id="other_policy_btn" + "🔀 Move to Other Policy", id="other_policy_btn" ) other_policy_btn.styles.width = "100%" other_policy_btn.styles.margin = (0, 0, 1, 0) @@ -305,7 +305,7 @@ class AgentMoveOperations(Widget): status_label.styles.margin = (2, 0, 0, 0) yield status_label - back_button = Button("← Back", id="back_button") + back_button = Button("Ò† Back", id="back_button") back_button.styles.width = "50%" back_button.styles.margin = (0, 1, 1, 0) yield back_button @@ -369,17 +369,17 @@ class AgentMoveOperations(Widget): pyperclip.copy(results_text.text) self.app.notify( - "πŸ“‹βœ… Results copied to clipboard!", + "Γ°ΕΈβ€œβ€ΉΓ’Ε“β€¦ Results copied to clipboard!", severity="information", timeout=2, ) except ImportError: self.app.notify( - "❌ pyperclip not installed. Run: pip install pyperclip", + "ҝŒ pyperclip not installed. Run: pip install pyperclip", severity="warning", ) except Exception as e: - self.app.notify(f"ҝŒ Failed to copy: {str(e)}", severity="error") + self.app.notify(f"ҝŒ Failed to copy: {str(e)}", severity="error") event.stop() elif btn_id == "export_csv_btn": self._start_export_csv_operation() @@ -427,7 +427,7 @@ class AgentMoveOperations(Widget): self.operation_in_progress = True status_label = self.query_one("#status_label", Static) - status_label.update("βœ”οΈ Moving agents to local approval...") + status_label.update("Òœ”️ Moving agents to local approval...") # Get API from app api = self.app.api @@ -462,12 +462,12 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error during local approval operation: {e}") - status_label.update(f"❌ Error: {str(e)}") + status_label.update(f"ҝŒ Error: {str(e)}") self.operation_in_progress = False return self.operation_in_progress = False - status_label.update("βœ… Operation complete!") + status_label.update("Òœ… Operation complete!") # Display results in the widget self._display_results("Local Approval Mode", successful, unsuccessful) @@ -511,9 +511,9 @@ class AgentMoveOperations(Widget): file_path = os.path.join(str(path), filename) df.to_csv(file_path, index=False) successful.append(file_path) - status_label.update(f"βœ… Exported to {file_path}") + status_label.update(f"Òœ… Exported to {file_path}") except Exception: - status_label.update("❌ Failed") + status_label.update("ҝŒ Failed") self.operation_in_progress = False @@ -557,7 +557,7 @@ class AgentMoveOperations(Widget): self.operation_in_progress = True status_label = self.query_one("#status_label", Static) - status_label.update("ҏ³ Toggling enforcement mode...") + status_label.update("ҏ³ Toggling enforcement mode...") # Get API from app api = self.app.api @@ -567,9 +567,9 @@ class AgentMoveOperations(Widget): try: from services.agenthandler import moveAgentToRelatedPolicy - from utils.configmanager import get_protected_json + from utils.configmanager import get_system_json - policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") + policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") for agent in self.agents: try: @@ -593,12 +593,12 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error during toggle enforcement operation: {e}") - status_label.update(f"❌ Error: {str(e)}") + status_label.update(f"ҝŒ Error: {str(e)}") self.operation_in_progress = False return self.operation_in_progress = False - status_label.update("Òœ… Operation complete!") + status_label.update("ΓƒΒ’Γ…β€œΓ’β‚¬Β¦ Operation complete!") # Display results in the widget self._display_results("Toggle Audit/Enforcement", successful, unsuccessful) @@ -663,7 +663,7 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error loading policies: {e}") - status_label.update(f"❌ Error: {str(e)}") + status_label.update(f"ҝŒ Error: {str(e)}") self.operation_in_progress = False self.selected_operation = "" self.app.notify(f"Failed to load policies: {str(e)}", severity="error") diff --git a/airlock_libs/airlock_libs.pyi b/airlock_libs/airlock_libs.pyi index 84bee85..137dc92 100644 --- a/airlock_libs/airlock_libs.pyi +++ b/airlock_libs/airlock_libs.pyi @@ -30,7 +30,7 @@ def history_logging( checkpoint_number: str, policy_names: str, ) -> List[Dict[str, Any]]: - """ + """ Query execution history logs from the Airlock API. Parameters diff --git a/default_system_config.json b/default_system_config.json index decfd79..2c003af 100644 --- a/default_system_config.json +++ b/default_system_config.json @@ -2,15 +2,38 @@ "APPNAME": "Loxide", "URL": "https://server:3129", "LOG_LEVEL": "INFO", - "BAD_PATH_PARTS": ["users","wwwroot","windows\\temp","windows\\task","windows\\system32","startup", "windows\\fonts","Recycle.Bin","AppData","programdata", "Solarwinds","kaseya"], - "BAD_PUBLISHERS": ["Brave", "Zoom", "GlavSoft", "VNC"], - "PUPS":["logmein","invalid","nmap","LTSvc","VNC","Kaseya","Solarwinds","mRemoteNG"], + "BAD_PATH_PARTS": [ + "users", + "wwwroot", + "windows\\temp", + "windows\\task", + "windows\\system32", + "startup", + "windows\\fonts", + "Recycle.Bin", + "AppData", + "programdata", + "Solarwinds", + "kaseya" + ], + "BAD_PUBLISHERS": [ + "Brave", + "Zoom", + "GlavSoft", + "VNC" + ], + "PUPS": [ + "logmein", + "invalid", + "nmap", + "LTSvc", + "VNC", + "Kaseya", + "Solarwinds", + "mRemoteNG" + ], "PATH_EXCLUSION_CONST": 4, "MIN_FILES_FOR_PATH": 4, "VT_THREAT_TOLERANCE": 4, - "TELEMETRY": "FALSE", - "TELEM_URL": "", - "POLICY_MAP_ENF_AUD": { - - } + "POLICY_MAP_ENF_AUD": {} } \ No newline at end of file diff --git a/flows/localApproval.py b/flows/localApproval.py index da89c8c..f1054f1 100644 --- a/flows/localApproval.py +++ b/flows/localApproval.py @@ -10,7 +10,7 @@ from typing import List, Optional from models.agent import Agent from services.agenthandler import moveAgentToRelatedPolicy, selectAgents from services.API import AirlockAPIWrapper -from utils.configmanager import get_protected_json +from utils.configmanager import get_system_json from utils.utils import colorText, get_sanitized_input logger = logging.getLogger(__name__) @@ -28,7 +28,7 @@ class LocalApprovalRequestor: username: Username creating the approvals (for tracking) """ self.api = api - self.policy_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") + self.policy_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") self.username = ( username or os.getenv("USERNAME") or os.getenv("USER") or "unknown" ) @@ -51,7 +51,7 @@ class LocalApprovalRequestor: batch_id = int(time.time()) purpose = ( - f"🎫 Local Approval 🎫 - {duration_minutes} mins - " + f"Γ°ΕΈΕ½Β« Local Approval Γ°ΕΈΕ½Β« - {duration_minutes} mins - " f"batch:{batch_id} Client:{agent_id} User:{self.username}" ) @@ -103,10 +103,10 @@ class LocalApprovalRequestor: success_count = 0 failure_count = 0 - print(colorText(f"\nπŸ“¦ Processing batch {batch_id}...", "cyan")) - print(colorText(f"πŸ‘€ Requested by: {self.username}", "cyan")) + print(colorText(f"\nΓ°ΕΈβ€œΒ¦ Processing batch {batch_id}...", "cyan")) + print(colorText(f"Γ°ΕΈβ€˜Β€ Requested by: {self.username}", "cyan")) print( - colorText(f"πŸ“Š Moving {len(agents)} agent(s) to local approval\n", "cyan") + colorText(f"Γ°ΕΈβ€œΕ  Moving {len(agents)} agent(s) to local approval\n", "cyan") ) for agent in agents: @@ -125,11 +125,11 @@ class LocalApprovalRequestor: if not move_success: raise Exception("Failed to move to audit policy") - print(colorText(f"βœ“ {agent.hostname}", "green")) + print(colorText(f"Γ’Ε“β€œ {agent.hostname}", "green")) success_count += 1 except Exception as e: - print(colorText(f"βœ— {agent.hostname}: {e}", "red")) + print(colorText(f"Γ’Ε“β€” {agent.hostname}: {e}", "red")) logger.error(f"Error processing agent {agent.hostname}: {e}") failure_count += 1 @@ -152,7 +152,7 @@ class LocalApprovalRequestor: ] # Display duration options - print(colorText("\n⏱️ Select Local Approval Duration:", "white")) + print(colorText("\nҏ±ï¸ Select Local Approval Duration:", "white")) print(colorText("=" * 50, "white")) for i, (minutes, label) in enumerate(duration_options, start=1): @@ -166,36 +166,36 @@ class LocalApprovalRequestor: if 1 <= choice <= len(duration_options): duration_minutes, duration_label = duration_options[choice - 1] - print(colorText(f"βœ“ Selected: {duration_label}", "green")) + print(colorText(f"Γ’Ε“β€œ Selected: {duration_label}", "green")) logger.info(f"User selected duration: {duration_minutes} minutes") else: - print(colorText("❌ Invalid choice.", "red")) + print(colorText("ҝŒ Invalid choice.", "red")) logger.warning("Invalid duration choice") return except ValueError: - print(colorText("❌ Invalid input. Please enter a number.", "red")) + print(colorText("ҝŒ Invalid input. Please enter a number.", "red")) logger.warning("Invalid input for duration selection") return # Select agents - print(colorText("\n🎯 Select Agents for Local Approval:", "white")) + print(colorText("\nΓ°ΕΈΕ½Β― Select Agents for Local Approval:", "white")) agents = selectAgents(self.api) if not agents: - print(colorText("❌ No agents found or error retrieving agents.", "red")) + print(colorText("ҝŒ No agents found or error retrieving agents.", "red")) logger.warning("No agents selected or error retrieving agents") return # Confirm with user - print(colorText("\nπŸ“‹ Summary:", "cyan")) + print(colorText("\nΓ°ΕΈβ€œβ€Ή Summary:", "cyan")) print(colorText(f" Duration: {duration_label}", "white")) print(colorText(f" Agents: {len(agents)}", "white")) confirm = get_sanitized_input("\nProceed? (y/n): ").lower() if confirm != "y": - print(colorText("❌ Operation cancelled.", "yellow")) + print(colorText("ҝŒ Operation cancelled.", "yellow")) return # Process the batch @@ -219,24 +219,25 @@ class LocalApprovalRequestor: failure_count: Number of failed operations """ print(colorText(f"\n{'=' * 60}", "white")) - print(colorText("πŸ“Š Local Approval Summary", "cyan")) + print(colorText("Γ°ΕΈβ€œΕ  Local Approval Summary", "cyan")) print(colorText("=" * 60, "white")) - print(colorText(f"βœ“ Successfully processed: {success_count}", "green")) + print(colorText(f"Γ’Ε“β€œ Successfully processed: {success_count}", "green")) if failure_count > 0: - print(colorText(f"βœ— Failed: {failure_count}", "red")) + print(colorText(f"Γ’Ε“β€” Failed: {failure_count}", "red")) - print(colorText(f"\nπŸ“¦ Batch ID: {batch_id}", "cyan")) - print(colorText(f"⏱️ Duration: {duration_label}", "cyan")) + print(colorText(f"\nΓ°ΕΈβ€œΒ¦ Batch ID: {batch_id}", "cyan")) + print(colorText(f"ҏ±ï¸ Duration: {duration_label}", "cyan")) print(colorText("=" * 60, "white")) - print(colorText("\nπŸ’‘ Next Steps:", "yellow")) - print(colorText(" β€’ Agents have been moved to audit policies", "white")) - print(colorText(" β€’ Local approvals are active", "white")) + print(colorText("\n💑 Next Steps:", "yellow")) + print(colorText(" Ò€’ Agents have been moved to audit policies", "white")) + print(colorText(" Ò€’ Local approvals are active", "white")) print( colorText( - f" β€’ Agents will return to enforcement after {duration_label}", "white" + f" Ò€’ Agents will return to enforcement after {duration_label}", + "white", ) ) print(colorText("=" * 60 + "\n", "white")) diff --git a/flows/prepPolicy.py b/flows/prepPolicy.py index 6f4243c..53f7ede 100644 --- a/flows/prepPolicy.py +++ b/flows/prepPolicy.py @@ -25,7 +25,7 @@ import pandas as pd from models.execution import ExecutionHistoryRecord from models.policy import Allowlist, Policy from services.API import AirlockAPIWrapper -from utils.configmanager import get_protected_value, load_env, load_env_json +from utils.configmanager import get_system_list, get_system_value, load_env from utils.selector import Selector from utils.utils import ( areYouSure, @@ -88,7 +88,7 @@ def sortHashes( ): working_dir = load_env("WORKING_DIR") history_days = Selector.select_value( - prompt="Enter how many days of history to pull (1–150): ", + prompt="Enter how many days of history to pull (1Γ’β‚¬β€œ150): ", value_type=int, valid_range=(1, 150), ) @@ -157,7 +157,7 @@ def buildPathsandPublishers(selected_policies: List[Policy], split): f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv" ) path2 = f"{working_dir}\\Approved\\{selected_policies[0].name}_needs_review_executions.csv" - path_exclusion_constant = get_protected_value("PATH_EXCLUSION_CONST", cast_type=int) + path_exclusion_constant = get_system_value("PATH_EXCLUSION_CONST", cast_type=int) if os.path.exists(path1): df1 = pd.read_csv(path1) @@ -227,7 +227,7 @@ def buildPathsandPublishers(selected_policies: List[Policy], split): all_approved_hashes["publisher"] != "Not Signed" ].drop_duplicates(subset=["publisher"]) # Remove Bad publisher if somehow they made it this far - pattern = regulator(load_env_json("BAD_PUBLISHERS", "[]")) + pattern = regulator(get_system_list("BAD_PUBLISHERS")) publist = publist[~publist["publisher"].str.contains(pattern, na=False)] publist = publist[["publisher"]] publist.sort_values(by="publisher", inplace=True) @@ -313,7 +313,7 @@ def buildPreflights(selected_policies: List[Policy]): def splitFilepathsGrouped(df, path_exclusion_constant, col="filename"): - min_files_for_path = get_protected_value("MIN_FILES_FOR_PATH", cast_type=int) + min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int) def clean_split(path): if not isinstance(path, (str, bytes, os.PathLike)): @@ -385,8 +385,8 @@ def calculatePath(approved_hashes, path_exclusion_constant, split): else: dfs_by_policy = [approved_hashes] - badpathparts = load_env_json("BAD_PATH_PARTS", "[]") - min_files_for_path = get_protected_value("MIN_FILES_FOR_PATH", cast_type=int) + badpathparts = get_system_list("BAD_PATH_PARTS") + min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int) processed_dfs = [] @@ -655,7 +655,7 @@ def section_header(title): def printEnforceChecklist(selected_policies, destination_policy, destination_allowlist): working_dir = load_env("WORKING_DIR") - section_header("πŸ› οΈ πŸ”’ Prepare to Enforce Policy πŸ› οΈ πŸ”’") + section_header("🛠️ 🔒 Prepare to Enforce Policy 🛠️ 🔒") print( colorText( "\nSequentially follow these steps to prepare a policy for enforcement:", @@ -670,11 +670,11 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all ) ) if not selected_policies: - print(colorText(" [βœ—] No policies have been chosen", "red")) + print(colorText(" [Γ’Ε“β€”] No policies have been chosen", "red")) else: print(colorText("The following policies have been chosen:", "green")) for policy in selected_policies: - print(colorText(f" [βœ“] {policy.name}", "green")) + print(colorText(f" [Γ’Ε“β€œ] {policy.name}", "green")) # Step 2: Destination Policy and Allowlist print( @@ -683,22 +683,22 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all if destination_policy: print( colorText( - f" [βœ“] {destination_policy[0].name} has been selected as the destination policy", + f" [Γ’Ε“β€œ] {destination_policy[0].name} has been selected as the destination policy", "green", ) ) else: - print(colorText(" [βœ—] No destination policy has been chosen", "red")) + print(colorText(" [Γ’Ε“β€”] No destination policy has been chosen", "red")) if destination_allowlist: print( colorText( - f" [βœ“] {destination_allowlist[0].name} has been selected as allowlist", + f" [Γ’Ε“β€œ] {destination_allowlist[0].name} has been selected as allowlist", "green", ) ) else: - print(colorText(" [βœ—] No allowlist has been chosen", "red")) + print(colorText(" [Γ’Ε“β€”] No allowlist has been chosen", "red")) # Step 3: Data Preparation print( @@ -713,9 +713,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [βœ“] Data has been fetched" + " [Γ’Ε“β€œ] Data has been fetched" if os.path.exists(review_path) - else " [βœ—] Data has not been fetched" + else " [Γ’Ε“β€”] Data has not been fetched" ), "green" if os.path.exists(review_path) else "red", ) @@ -723,7 +723,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [βœ—] No policies selected, cannot check data fetch status", "red" + " [Γ’Ε“β€”] No policies selected, cannot check data fetch status", "red" ) ) @@ -756,9 +756,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [βœ“] Reviewed hashes have been loaded" + " [Γ’Ε“β€œ] Reviewed hashes have been loaded" if os.path.exists(approved_path) - else " [βœ—] Reviewed hashes have not been loaded" + else " [Γ’Ε“β€”] Reviewed hashes have not been loaded" ), "green" if os.path.exists(approved_path) else "red", ) @@ -766,9 +766,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [βœ“] Path review list created" + " [Γ’Ε“β€œ] Path review list created" if os.path.exists(second_review_path) - else " [βœ—] Path review list has not been created" + else " [Γ’Ε“β€”] Path review list has not been created" ), "green" if os.path.exists(second_review_path) else "red", ) @@ -776,7 +776,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [βœ—] No policies selected, cannot check reviewed hashes or path list", + " [Γ’Ε“β€”] No policies selected, cannot check reviewed hashes or path list", "red", ) ) @@ -812,9 +812,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [βœ“] Reviewed path list detected" + " [Γ’Ε“β€œ] Reviewed path list detected" if os.path.exists(reviewed_path) - else " [βœ—] Path review list has not been detected" + else " [Γ’Ε“β€”] Path review list has not been detected" ), "green" if os.path.exists(reviewed_path) else "red", ) @@ -825,9 +825,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [βœ“] Preflight Path Exclusion List has been generated" + " [Γ’Ε“β€œ] Preflight Path Exclusion List has been generated" if preflight_ready - else " [βœ—] Preflight Path Exclusion List has not been generated" + else " [Γ’Ε“β€”] Preflight Path Exclusion List has not been generated" ), "green" if preflight_ready else "red", ) @@ -835,7 +835,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [βœ—] No policies selected, cannot check preflight status", "red" + " [Γ’Ε“β€”] No policies selected, cannot check preflight status", "red" ) ) @@ -866,5 +866,5 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print(colorText(" Apply approved hashes to allowlist", "cyan")) # Utility Options - print(colorText("F. πŸ“‚ - Open Working Directory", "cyan")) - print(colorText("B. πŸ”š - Back", "cyan")) + print(colorText("F. Γ°ΕΈβ€œβ€š - Open Working Directory", "cyan")) + print(colorText("B. ðŸ”ő - Back", "cyan")) diff --git a/flows/quietAgent.py b/flows/quietAgent.py deleted file mode 100644 index d723da7..0000000 --- a/flows/quietAgent.py +++ /dev/null @@ -1,134 +0,0 @@ -# Copyright (C) 2025 James Brotosky, Brandon Wickline -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import datetime -import logging - -import dotenv -import pandas as pd - -from flows.prepPolicy import selectPolicies -from services.API import AirlockAPIWrapper -from services.policyhandler import getPolicyInfo -from utils.configmanager import load_env -from utils.selector import Selector -from utils.utils import colorText, get_sanitized_input - -logger = logging.getLogger(__name__) - - -dotenv.load_dotenv() - - -def findQuietAgents(api: AirlockAPIWrapper): - working_dir = load_env("WORKING_DIR") - # Get policy selection and agent list - selected_policy = selectPolicies(api, False) - if selected_policy: - agents = api.agents_find_by_group(selected_policy[0].groupid) - - # Prompt user for history range - history_days = Selector.select_value( - prompt="Enter how many days of history to pull (1–150): ", - value_type=int, - valid_range=(1, 150), - ) - required_quiet = Selector.select_value( - prompt="Enter how many days without an untrusted execution before these are considered ready for enforcement? (1–365): ", - value_type=int, - valid_range=(1, 150), - ) - - confirm = Selector.confirm( - f"Do you wish to proceed to pull history for {selected_policy[0].name}? Y/N : " - ) - # Get execution history as a DataFrame - if confirm: - policy_exec_history = getPolicyInfo( - api, selected_policy[0], [1, 2, 6, 7], history_days - ) - - if policy_exec_history.empty: - logging.info( - "No execution history found for the selected policy and time range." - ) - get_sanitized_input("Press enter to continue") - return - - # Convert 'datetime' column to timezone-aware datetime objects - policy_exec_history["datetime"] = pd.to_datetime( - policy_exec_history["datetime"], format="%Y-%m-%dT%H:%M:%SZ", utc=True - ) - - # Get current UTC time - now = datetime.datetime.now(datetime.timezone.utc) - - # Calculate days ago - policy_exec_history["days_ago"] = policy_exec_history["datetime"].apply( - lambda dt: (now - dt).days - ) - - # Count total executions per hostname - hostname_counts = policy_exec_history["hostname"].value_counts() - - # Map execution counts to agents - agents["execution_count"] = ( - agents["hostname"].map(hostname_counts).fillna(0).astype(int) - ) - - # Find most recent execution per hostname - most_recent_exec = policy_exec_history.sort_values( - by="days_ago" - ).drop_duplicates(subset="hostname", keep="first") - - # Map most recent execution age to agents - agents["days_since"] = agents["hostname"].map( - most_recent_exec.set_index("hostname")["days_ago"] - ) - - # Check for enforcement readiness - agents["required_quiet"] = required_quiet - agents["enforce_ready"] = agents["days_since"].apply( - lambda x: True if pd.isna(x) or x > required_quiet else False - ) - - # Sort agents by execution count and hostname - agents = agents.sort_values( - by=["execution_count", "hostname"], ascending=[True, True] - ) - - # Save to CSV - filename = f"{working_dir}\\{selected_policy[0].name}_agents_last_{history_days}_days.csv" - logging.debug(f"Saving CSV to {filename}") - print(colorText(f"Saving CSV to {filename}", "green")) - agents.to_csv(filename, index=False) - - # Summary statistics - total_agents = len(agents) - ready_agents = agents["enforce_ready"].sum() - not_ready_agents = total_agents - ready_agents - ready_percentage = (ready_agents / total_agents) * 100 - - # Print results - - message = ( - f"Total agents: {total_agents}\n" - f"Agents marked as 'enforce_ready': {ready_agents}\n" - f"Agents not ready: {not_ready_agents}\n" - f"Percentage ready for enforcement: {ready_percentage:.2f}%" - ) - logger.debug(message) - colorText(message, "green") - get_sanitized_input("Press enter to continue") diff --git a/models/execution.py b/models/execution.py index e1cf514..462193d 100644 --- a/models/execution.py +++ b/models/execution.py @@ -28,7 +28,7 @@ import pandas as pd import airlock_libs from services.API import AirlockAPIWrapper -from utils.configmanager import get_protected_value, load_env_json +from utils.configmanager import get_system_list, get_system_value from utils.utils import colorText, regulator logger = logging.getLogger(__name__) @@ -90,9 +90,9 @@ class Hash: @classmethod def categorize_hashes(cls, hashes): - threat_tolerance = get_protected_value("VT_THREAT_TOLERANCE", cast_type=int) - bad_publishers_pattern = regulator(load_env_json("BAD_PUBLISHERS", "[]")) - pups_pattern = regulator(load_env_json("PUPS", "[]")) + threat_tolerance = get_system_value("VT_THREAT_TOLERANCE", cast_type=int) + bad_publishers_pattern = regulator(get_system_list("BAD_PUBLISHERS")) + pups_pattern = regulator(get_system_list("PUPS")) approved_count = 0 unapproved_count = 0 @@ -145,13 +145,13 @@ class Hash: approved_count += 1 except (ValueError, TypeError): logger.debug( - "Needs Review: Scannermatch score is missing or invalid. β€” {e}" + "Needs Review: Scannermatch score is missing or invalid. Ò€” {e}" ) hash_obj.at_decision = "needs_review" needs_review_count += 1 logger.debug( - f"Final counts β€” Needs Review: {needs_review_count}, Approved: {approved_count}, Unapproved: {unapproved_count}" + f"Final counts Ò€” Needs Review: {needs_review_count}, Approved: {approved_count}, Unapproved: {unapproved_count}" ) return hashes @@ -384,9 +384,9 @@ class ExecutionHistoryRecord: Returns: List[ExecutionHistoryRecord]: The same list, with hash_obj.at_decision updated. """ - threat_tolerance = get_protected_value("VT_THREAT_TOLERANCE", cast_type=int) - bad_publishers_pattern = regulator(load_env_json("BAD_PUBLISHERS", "[]")) - pups_pattern = regulator(load_env_json("PUPS", "[]")) + threat_tolerance = get_system_value("VT_THREAT_TOLERANCE", cast_type=int) + bad_publishers_pattern = regulator(get_system_list("BAD_PUBLISHERS")) + pups_pattern = regulator(get_system_list("PUPS")) approved_count = 0 unapproved_count = 0 @@ -443,13 +443,13 @@ class ExecutionHistoryRecord: approved_count += 1 except (ValueError, TypeError) as e: logger.debug( - f"Needs Review: Scannermatch score is missing or invalid. β€” {e}" + f"Needs Review: Scannermatch score is missing or invalid. Ò€” {e}" ) hash_obj.at_decision = "needs_review" needs_review_count += 1 logger.debug( - f"Final counts β€” Needs Review: {needs_review_count}, " + f"Final counts Ò€” Needs Review: {needs_review_count}, " f"Approved: {approved_count}, Unapproved: {unapproved_count}" ) diff --git a/services/agenthandler.py b/services/agenthandler.py index 0ec3d3b..7a788e6 100644 --- a/services/agenthandler.py +++ b/services/agenthandler.py @@ -28,7 +28,7 @@ from flows.prepPolicy import selectPolicies from models.agent import Agent from models.policy import Policy from services.API import AirlockAPIWrapper -from utils.configmanager import get_protected_json, load_env +from utils.configmanager import get_system_json, load_env from utils.selector import Selector from utils.utils import colorText, get_sanitized_input @@ -38,7 +38,7 @@ logger = logging.getLogger(__name__) def devicehistory(api: AirlockAPIWrapper, outputjson: bool): agents = selectAgents(api) history_days = Selector.select_value( - prompt="Enter how many days of history to pull (1–150): ", + prompt="Enter how many days of history to pull (1Γ’β‚¬β€œ150): ", value_type=int, valid_range=(1, 150), ) @@ -60,7 +60,7 @@ def devicehistory(api: AirlockAPIWrapper, outputjson: bool): except Exception as e: print( colorText( - f"❌ Error retrieving history for {agent.hostname}: {e}", "red" + f"ҝŒ Error retrieving history for {agent.hostname}: {e}", "red" ) ) continue @@ -139,7 +139,7 @@ def findAgents(api, return_dataframe): print( colorText( - f"\nβœ… Matched devices exported to: {working_dir}\\{filename}", + f"\nÒœ… Matched devices exported to: {working_dir}\\{filename}", "green", ) ) @@ -148,7 +148,7 @@ def findAgents(api, return_dataframe): def collect_device_names() -> List[str]: - print(colorText("πŸ” Device Search", "cyan")) + print(colorText("🔍 Device Search", "cyan")) print( colorText( "Enter the device hostnames you'd like to search for, one per line.", "cyan" @@ -185,7 +185,7 @@ def collect_device_names() -> List[str]: else: print( colorText( - f"⚠️ Invalid input: '{stripped_line}' β€” only letters, numbers, underscores, spaces, and hyphens are allowed.", + f"Òő ï¸ Invalid input: '{stripped_line}' Ò€” only letters, numbers, underscores, spaces, and hyphens are allowed.", "yellow", ) ) @@ -235,8 +235,8 @@ def show_unmatched( ] if unmatched: - logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}") - print(colorText(f"⚠️ No matches for: {', '.join(unmatched)}", "yellow")) + logger.debug(f"Òő ï¸ No matches for: {', '.join(unmatched)}") + print(colorText(f"Òő ï¸ No matches for: {', '.join(unmatched)}", "yellow")) def enrich_agents(agents: List["Agent"], policies: List["Policy"]): @@ -248,7 +248,7 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: device_names = collect_device_names() if not device_names: logger.debug("No device names entered") - print(colorText("⚠️ No device names entered.", "red")) + print(colorText("Òő ï¸ No device names entered.", "red")) return [] use_exact = choose_match_type() @@ -261,11 +261,11 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: show_unmatched(device_names, matched_agents, use_exact) if not matched_agents: - logger.debug("❌ No matching devices found.") - print(colorText("❌ No matching devices found.", "red")) + logger.debug("ҝŒ No matching devices found.") + print(colorText("ҝŒ No matching devices found.", "red")) return [] - print(colorText(f"βœ… Found {len(matched_agents)} matching device(s).", "green")) + print(colorText(f"Òœ… Found {len(matched_agents)} matching device(s).", "green")) logger.info("Matched agent hostnames:") rows = (len(matched_agents) + 2) // 3 # 3 columns for row in range(rows): @@ -283,8 +283,8 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: ) if not matched_agents: - logger.debug("❌ No matching devices remain after refinement.") - print(colorText("❌ No matching devices remain after refinement.", "red")) + logger.debug("ҝŒ No matching devices remain after refinement.") + print(colorText("ҝŒ No matching devices remain after refinement.", "red")) return [] enrich_agents(matched_agents, policies) @@ -302,10 +302,10 @@ def moveAgentToRelatedPolicy( Args: api: AirlockAPIWrapper instance. agent: Agent object. - policy_relationship_map: Dict mapping enforcement β†’ audit. + policy_relationship_map: Dict mapping enforcement Ò†’ audit. mode: 'audit' to move to audit, 'enforcement' to move to enforcement. """ - policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") + policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") if mode == "audit": if agent.groupid in policy_relationship_map: diff --git a/services/policyhandler.py b/services/policyhandler.py index c32cdfc..0fa6a11 100644 --- a/services/policyhandler.py +++ b/services/policyhandler.py @@ -27,7 +27,7 @@ import tqdm from models.policy import Policy from services.API import AirlockAPIWrapper -from utils.configmanager import get_protected_json +from utils.configmanager import get_system_json from utils.setup import get_base_directory from utils.utils import areYouSure, colorText, get_sanitized_input @@ -236,7 +236,7 @@ def skipback(days): def updateAuditPoliciesFromEnforcementPolices(api: AirlockAPIWrapper): - policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") + policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") for enforcement_policy, audit_policy in policy_relationship_map.items(): api.policy_clone(enforcement_policy, audit_policy) api.policy_set_auditmode(audit_policy, "1") diff --git a/utils/configmanager.py b/utils/configmanager.py index 7b5aea5..7ca49ed 100644 --- a/utils/configmanager.py +++ b/utils/configmanager.py @@ -18,119 +18,279 @@ import logging import os from pathlib import Path import sys -from typing import Callable, Optional, TypeVar +from typing import Any, Callable, Optional, TypeVar T = TypeVar("T") logger = logging.getLogger(__name__) -PROTECTED_KEYS = [ +# System config keys - these are immutable and come from system_config.json (bundled in exe) +SYSTEM_CONFIG_KEYS = [ "URL", "TELEM_URL", "APPNAME", "LOG_LEVEL", + "BAD_PATH_PARTS", + "BAD_PUBLISHERS", + "PUPS", "PATH_EXCLUSION_CONST", "MIN_FILES_FOR_PATH", "VT_THREAT_TOLERANCE", "POLICY_MAP_ENF_AUD", ] -_protected_config = {} +# User config keys - these can be changed by the end user +USER_CONFIG_KEYS = [ + "TELEMETRY", # User opt-in/out for telemetry + "TEXTUAL_THEME", # UI theme preference + "EXTRAS", # Feature flags +] + +# In-memory config storage +_system_config = {} +_user_config = {} def get_system_config_path() -> Path: + """ + Get path to system_config.json. + Priority: + 1. Bundled in exe (_MEIPASS) + 2. Next to this file (development) + """ # Check inside bundled EXE directory first bundled_dir = Path(getattr(sys, "_MEIPASS", "")) bundled_path = bundled_dir / "system_config.json" if bundled_path.exists(): return bundled_path - # Fallback to external location + # Fallback to development location (next to this file) return Path(__file__).parent.parent / "system_config.json" -def load_protected_config() -> dict: - global _protected_config +def load_system_config() -> dict: + """ + Load system configuration from system_config.json. + This should only be called once at startup. + Returns the full system config dict. + """ + global _system_config + try: - with open(get_system_config_path(), "r") as f: - system_config = json.load(f) + config_path = get_system_config_path() + with open(config_path, "r") as f: + _system_config = json.load(f) + logger.debug(f"βœ… Loaded system config from {config_path}") except FileNotFoundError: - logging.warning("⚠️ system_config.json not found. Using built-in defaults.") - system_config = { + logger.warning("⚠️ system_config.json not found. Using minimal defaults.") + # Minimal defaults for development without system_config.json + _system_config = { "APPNAME": "Loxide", + "LOG_LEVEL": "INFO", "PATH_EXCLUSION_CONST": 4, "MIN_FILES_FOR_PATH": 4, "VT_THREAT_TOLERANCE": 4, - "POLICY_MAP_ENF_AUD": {"enforced_id": "audit_id"}, + "POLICY_MAP_ENF_AUD": {}, } - _protected_config = {key: system_config[key] for key in PROTECTED_KEYS} - return _protected_config + return _system_config -def get_protected_value( +def get_system_value( key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None ) -> Optional[T]: - value = _protected_config.get(key) + """ + Get a value from system config (immutable). + + Parameters: + key: The config key to retrieve + cast_type: Function to cast the value to desired type + default: Default value if key not found + + Returns: + The config value cast to the desired type, or default + """ + value = _system_config.get(key) if value is None: - logging.warning(f"Protected config key '{key}' not found.") + logger.warning(f"System config key '{key}' not found.") return default + try: if isinstance(value, str): value = value.strip("'\"") return cast_type(value) except (ValueError, TypeError): - logging.warning( - f"Invalid value for protected key '{key}': {value}. Expected type {cast_type.__name__}." + logger.warning( + f"Invalid value for system key '{key}': {value}. Expected type {cast_type.__name__}." ) return default -def get_protected_json(key: str, default: str = "{}") -> dict: - raw = _protected_config.get(key, default) +def get_system_json(key: str, default: Optional[dict] = None) -> dict: + """ + Get a JSON/dict value from system config. + Handles both dict values and JSON strings. + """ + if default is None: + default = {} + + raw = _system_config.get(key, default) if isinstance(raw, dict): return raw + try: return json.loads(raw) - except json.JSONDecodeError: - try: - escaped = raw.encode("unicode_escape").decode("utf-8") - return json.loads(escaped) - except Exception as e: - logging.error(f"Failed to parse protected JSON key '{key}': {e}") - return json.loads(default) + except (json.JSONDecodeError, TypeError) as e: + logger.error(f"Failed to parse system JSON key '{key}': {e}") + return default -def load_env_json(key: str, default: str): - raw = os.getenv(key, default) +def get_system_list(key: str, default: Optional[list] = None) -> list: + """ + Get a list value from system config. + Handles both list values and JSON strings. + + Parameters: + key: The config key to retrieve + default: Default value if key not found or parsing fails + + Returns: + The list value or default + """ + if default is None: + default = [] + + raw = _system_config.get(key, default) + if isinstance(raw, list): + return raw + try: - return json.loads(raw) - except json.JSONDecodeError: - try: - escaped = raw.encode("unicode_escape").decode("utf-8") - return json.loads(escaped) - except Exception as e: - logging.error(f"Failed to parse {key}: {e}") - return json.loads(default) + result = json.loads(raw) if isinstance(raw, str) else raw + if isinstance(result, list): + return result + logger.warning(f"System config key '{key}' is not a list: {type(result)}") + return default + except (json.JSONDecodeError, TypeError) as e: + logger.error(f"Failed to parse system list key '{key}': {e}") + return default + + +def load_user_config(config_dir: Path) -> dict: + """ + Load user configuration from user_config.json. + Creates the file with defaults if it doesn't exist. + + Parameters: + config_dir: Directory containing user_config.json + + Returns: + The user config dict + """ + global _user_config + + user_config_path = config_dir / "user_config.json" + + if not user_config_path.exists(): + # Create default user config + default_user_config = { + "TELEMETRY": "false", + "TEXTUAL_THEME": "gruvbox", + "EXTRAS": "NOTTODAY", + } + user_config_path.parent.mkdir(parents=True, exist_ok=True) + with open(user_config_path, "w") as f: + json.dump(default_user_config, f, indent=4) + logger.debug(f"Created default user config at {user_config_path}") + _user_config = default_user_config + else: + with open(user_config_path, "r") as f: + _user_config = json.load(f) + logger.debug(f"βœ… Loaded user config from {user_config_path}") + + return _user_config + + +def save_user_config(config_dir: Path, updates: dict) -> None: + """ + Save updates to user configuration. + Only keys in USER_CONFIG_KEYS are allowed. + + Parameters: + config_dir: Directory containing user_config.json + updates: Dict of key-value pairs to update + """ + global _user_config + + # Validate that only user-configurable keys are being updated + invalid_keys = [k for k in updates.keys() if k not in USER_CONFIG_KEYS] + if invalid_keys: + logger.error(f"Attempted to save invalid user config keys: {invalid_keys}") + raise ValueError(f"Cannot modify system config keys: {invalid_keys}") + + # Update in-memory config + _user_config.update(updates) + + # Write to file + user_config_path = config_dir / "user_config.json" + user_config_path.parent.mkdir(parents=True, exist_ok=True) + with open(user_config_path, "w") as f: + json.dump(_user_config, f, indent=4) + + logger.debug(f"βœ… Saved user config to {user_config_path}: {updates}") + + +def get_user_value( + key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None +) -> Optional[T]: + """ + Get a value from user config (mutable). + + Parameters: + key: The config key to retrieve + cast_type: Function to cast the value to desired type + default: Default value if key not found + + Returns: + The config value cast to the desired type, or default + """ + value = _user_config.get(key) + if value is None: + logger.warning(f"User config key '{key}' not found.") + return default + + try: + if isinstance(value, str): + value = value.strip("'\"") + return cast_type(value) + except (ValueError, TypeError): + logger.warning( + f"Invalid value for user key '{key}': {value}. Expected type {cast_type.__name__}." + ) + return default def load_env( key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None ) -> Optional[T]: """ - Safely retrieves an environment variable and casts it to the desired type. + Safely retrieves an environment variable from .env and casts it to the desired type. + This should ONLY be used for runtime/dynamic values like WORKING_DIR. + + For system config, use get_system_value(). + For user config, use get_user_value(). Parameters: - key (str): The name of the environment variable. - cast_type (Callable[[str], T], optional): Function to cast the value. Defaults to str. - default (Optional[T], optional): Default value if the variable is not set or invalid. + key: The name of the environment variable + cast_type: Function to cast the value. Defaults to str + default: Default value if the variable is not set or invalid Returns: - Optional[T]: The casted value or the default. + The casted value or the default """ value = os.getenv(key) if value is None: - logger.warning(f"Environment variable '{key}' not set.") + logger.debug(f"Environment variable '{key}' not set, using default.") return default + try: value = value.strip("'\"") # Strip surrounding quotes return cast_type(value) @@ -139,3 +299,46 @@ def load_env( f"Invalid value for env var '{key}': {value}. Expected type {cast_type.__name__}." ) return default + + +def load_env_json(key: str, default: str = "[]") -> Any: + """ + Load a JSON value from environment or system config. + + DEPRECATED: This function is kept for backward compatibility. + - For system config lists (BAD_PUBLISHERS, PUPS, BAD_PATH_PARTS), use get_system_list() + - For system config dicts, use get_system_json() + - For actual .env JSON values, parse manually + + This function automatically redirects known system config keys to system config. + """ + # Known system config list keys - redirect to system config + system_list_keys = ["BAD_PUBLISHERS", "PUPS", "BAD_PATH_PARTS"] + if key in system_list_keys: + logger.debug(f"Redirecting load_env_json('{key}') to get_system_list()") + return get_system_list(key, json.loads(default) if default else []) + + # Known system config dict keys - redirect to system config + system_dict_keys = ["POLICY_MAP_ENF_AUD"] + if key in system_dict_keys: + logger.debug(f"Redirecting load_env_json('{key}') to get_system_json()") + return get_system_json(key, json.loads(default) if default else {}) + + # Fall back to reading from .env (backward compatibility for unknown keys) + raw = os.getenv(key, default) + try: + return json.loads(raw) + except json.JSONDecodeError: + try: + escaped = raw.encode("unicode_escape").decode("utf-8") + return json.loads(escaped) + except Exception as e: + logger.error(f"Failed to parse {key}: {e}") + return json.loads(default) + + +# Backwards compatibility aliases (deprecated - use get_system_value instead) +get_protected_value = get_system_value +get_protected_json = get_system_json +load_protected_config = load_system_config +PROTECTED_KEYS = SYSTEM_CONFIG_KEYS # For backwards compatibility diff --git a/utils/setup.py b/utils/setup.py index 77bc5a0..ee4a384 100644 --- a/utils/setup.py +++ b/utils/setup.py @@ -13,18 +13,20 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json import logging import logging.config import logging.handlers import os from pathlib import Path import platform -import sys from dotenv import load_dotenv, set_key -from utils.configmanager import PROTECTED_KEYS, load_protected_config +from utils.configmanager import ( + get_system_value, + load_system_config, + load_user_config, +) def get_base_directory() -> Path: @@ -38,7 +40,7 @@ def get_base_directory() -> Path: return home / ".local" / "share" / "Loxide" -def configure_logging(log_dir: Path, log_level: str = "DEBUG"): +def configure_logging(log_dir: Path, log_level: str = "INFO"): log_file = log_dir / "Loxide.log" config = { @@ -62,12 +64,12 @@ def configure_logging(log_dir: Path, log_level: str = "DEBUG"): "interval": 1, # Every 1 day "backupCount": 7, # Keep 7 days of logs "encoding": "utf-8", # Ensure UTF-8 encoding - "level": "DEBUG", # Always log DEBUG and above + "level": "DEBUG", # Always log DEBUG and above to file "formatter": "detailed", # Use detailed format }, "console": { "class": "logging.StreamHandler", - "level": log_level.upper(), # Configurable log level + "level": log_level.upper(), # System-configured level for console "formatter": "simple", # Use simple format }, }, @@ -95,59 +97,15 @@ def configure_logging(log_dir: Path, log_level: str = "DEBUG"): logging.getLogger().debug("βœ… Logging configured.") -def get_system_config_path() -> Path: - base_path = Path( - getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) - ) - return base_path.parent / "system_config.json" - - -def load_system_config() -> dict: - try: - config_path = get_system_config_path() - with open(config_path, "r") as f: - return json.load(f) - except FileNotFoundError: - logging.warning("⚠️ system_config.json not found. Using built-in defaults.") - return { - "APPNAME": "Loxide", - "LOG_LEVEL": "DEBUG", - "PATH_EXCLUSION_CONST": 4, - "MIN_FILES_FOR_PATH": 4, - "VT_THREAT_TOLERANCE": 4, - "POLICY_MAP_ENF_AUD": {"enforced_id": "audit_id"}, - } - - -def load_user_config(config_dir: Path) -> dict: - user_config_path = config_dir / "user_config.json" - if not user_config_path.exists(): - default_user_config = { - "TELEMETRY": "FALSE", - "TEXTUAL_THEME": "gruvbox", - "EXTRAS": "NOTTODAY", - } - with open(user_config_path, "w") as f: - json.dump(default_user_config, f, indent=4) - logging.debug(f"Created user config at {user_config_path}") - with open(user_config_path, "r") as f: - return json.load(f) - - -def write_config_to_env(config: dict, env_path: Path): - for key, value in config.items(): - if key in PROTECTED_KEYS: - continue # Skip protected keys - try: - serialized = ( - json.dumps(value) if isinstance(value, (list, dict)) else str(value) - ) - set_key(env_path, key, serialized) - except Exception as e: - logging.warning(f"Failed to write {key} to .env: {e}") - - def setup(): + """ + Initialize the application environment: + 1. Create directory structure + 2. Load system config (immutable, from system_config.json) + 3. Load user config (mutable, from user_config.json) + 4. Configure logging + 5. Set up .env with WORKING_DIR only + """ base_dir = get_base_directory() dirs = { "config": base_dir / "config", @@ -159,20 +117,30 @@ def setup(): path.mkdir(parents=True, exist_ok=True) logging.debug(f"{name.capitalize()} directory ensured at: {path}") + # Load system config (immutable) system_config = load_system_config() - configure_logging(dirs["logs"], system_config.get("LOG_LEVEL", "DEBUG")) + # Configure logging with system-defined log level + log_level = get_system_value("LOG_LEVEL", str, "INFO") + configure_logging(dirs["logs"], log_level) + + # Load user config (mutable) + user_config = load_user_config(dirs["config"]) + + # Set up .env file - ONLY for WORKING_DIR (runtime-configurable value) env_path = base_dir / ".env" if not env_path.exists(): env_path.touch() load_dotenv(dotenv_path=env_path, override=True) + # Set up working directory (only dynamic value in .env) working_dir = Path(os.getenv("WORKING_DIR") or (base_dir / "data")) working_dir.mkdir(parents=True, exist_ok=True) - set_key(env_path, "WORKING_DIR", str(working_dir)) + set_key(str(env_path), "WORKING_DIR", str(working_dir)) os.environ["WORKING_DIR"] = str(working_dir) logging.debug(f"Working directory set to: {working_dir}") + # Create folder structure in working directory folders_structure = { "Approved": [], "Needs_Review": ["Review_First", "Review_Second", "HTML"], @@ -189,23 +157,4 @@ def setup(): subfolder_path.mkdir(parents=True, exist_ok=True) logging.debug(f" └─ '{subfolder}' subfolder created at: {subfolder_path}") - user_config = load_user_config(dirs["config"]) - merged_config = {**system_config, **user_config} - - protected_config = load_protected_config() - merged_config.update(protected_config) - - # βœ… URL resolution order: system_config β†’ .env β†’ user prompt - url = system_config.get("URL") - if not url: - url = os.getenv("URL") - if not url: - url = input( - "🌐 Enter the service URL (e.g., https://example.com/api): " - ).strip() - merged_config["URL"] = url - set_key(env_path, "URL", url) - os.environ["URL"] = url - logging.debug(f"Service URL set to: {url}") - - write_config_to_env(merged_config, env_path) + logging.info("βœ… Setup complete") diff --git a/utils/test.py b/utils/test.py deleted file mode 100644 index e69de29..0000000 From 2f41b33dd4df78dbc21cbbbf25d07d8ed04738be Mon Sep 17 00:00:00 2001 From: Zarithas Date: Mon, 17 Nov 2025 12:06:17 -0500 Subject: [PATCH 23/29] Moved TELEM_URL from system config to user config --- utils/configmanager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/configmanager.py b/utils/configmanager.py index 7ca49ed..b008404 100644 --- a/utils/configmanager.py +++ b/utils/configmanager.py @@ -26,7 +26,6 @@ logger = logging.getLogger(__name__) # System config keys - these are immutable and come from system_config.json (bundled in exe) SYSTEM_CONFIG_KEYS = [ "URL", - "TELEM_URL", "APPNAME", "LOG_LEVEL", "BAD_PATH_PARTS", @@ -41,6 +40,7 @@ SYSTEM_CONFIG_KEYS = [ # User config keys - these can be changed by the end user USER_CONFIG_KEYS = [ "TELEMETRY", # User opt-in/out for telemetry + "TELEM_URL", "TEXTUAL_THEME", # UI theme preference "EXTRAS", # Feature flags ] From 4a47cbe661ccc3ade892f34bfcd0266d39717afc Mon Sep 17 00:00:00 2001 From: Zarithas Date: Mon, 17 Nov 2025 12:11:03 -0500 Subject: [PATCH 24/29] Added TELEM_URL default value in Unbuilt User Config --- utils/configmanager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/utils/configmanager.py b/utils/configmanager.py index b008404..a5f59bb 100644 --- a/utils/configmanager.py +++ b/utils/configmanager.py @@ -193,6 +193,7 @@ def load_user_config(config_dir: Path) -> dict: # Create default user config default_user_config = { "TELEMETRY": "false", + "TELEM_URL": "", "TEXTUAL_THEME": "gruvbox", "EXTRAS": "NOTTODAY", } From a7fc1b71e1285e8298f0ec92d5460d61975a1b22 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Mon, 17 Nov 2025 12:24:09 -0500 Subject: [PATCH 25/29] Minor default user config tweak --- services/API.py | 8 ++++++++ utils/configmanager.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/services/API.py b/services/API.py index 7fab0ac..928b1f4 100644 --- a/services/API.py +++ b/services/API.py @@ -115,6 +115,14 @@ class AirlockAPIWrapper: payload = {"groupid": groupid} result = self._post("/v1/agent/find", payload) return pd.DataFrame(result["response"]["agents"]) + + #Baseline Managment + def baseline_find_all(self) -> pd.DataFrame: + """Retrieve all agents.""" + result = self._post("/v1/agent/find", {}) + return pd.DataFrame(result["response"]["agents"]) + + # Hash Management def hash_add_to_allowlist(self, applicationid: str, hashes: List[str]) -> dict: diff --git a/utils/configmanager.py b/utils/configmanager.py index a5f59bb..0d5391a 100644 --- a/utils/configmanager.py +++ b/utils/configmanager.py @@ -192,7 +192,7 @@ def load_user_config(config_dir: Path) -> dict: if not user_config_path.exists(): # Create default user config default_user_config = { - "TELEMETRY": "false", + "TELEMETRY": False, "TELEM_URL": "", "TEXTUAL_THEME": "gruvbox", "EXTRAS": "NOTTODAY", From fbb5cc396b01e4df1dfc9379f624a7468c1609d7 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Mon, 17 Nov 2025 12:55:35 -0500 Subject: [PATCH 26/29] Expanded API Wrapper --- services/API.py | 49 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/services/API.py b/services/API.py index 928b1f4..22cfa7d 100644 --- a/services/API.py +++ b/services/API.py @@ -64,6 +64,23 @@ class AirlockAPIWrapper: logger.error(f"API request failed: {e}") raise + def _post_raw(self, endpoint: str, payload: Optional[dict] = None) -> bytes: + """ + Send POST request and return raw response content (bytes). + Useful for XML endpoints. + """ + url = f"{self.base_url}{endpoint}" + data = json.dumps(payload or {}) + try: + logger.debug(f"POST Request to {url} with payload: {payload}") + response = requests.post(url, headers=self.headers, data=data, verify=False) + response.raise_for_status() + logger.debug(f"Raw response received from {url}") + return response.content # bytes + except requests.exceptions.RequestException as e: + logger.error(f"API request failed: {e}") + raise + # Allowlist Management def allowlist_find_all(self) -> pd.DataFrame: """ @@ -75,6 +92,12 @@ class AirlockAPIWrapper: result = self._post("/v1/application", {}) return pd.DataFrame(result["response"]["applications"]) + def allowlist_export(self, applicationid) -> bytes: + """Return Allowlist XML as bytes to save to file""" + payload = {"applicationid": applicationid} + result = self._post_raw("/v1/application/export", payload) + return result # should be bytes + # Agent Management def agent_find_all(self) -> pd.DataFrame: """Retrieve all agents.""" @@ -115,14 +138,30 @@ class AirlockAPIWrapper: payload = {"groupid": groupid} result = self._post("/v1/agent/find", payload) return pd.DataFrame(result["response"]["agents"]) - - #Baseline Managment + + # Baseline Managment def baseline_find_all(self) -> pd.DataFrame: - """Retrieve all agents.""" - result = self._post("/v1/agent/find", {}) - return pd.DataFrame(result["response"]["agents"]) + """Retrieve all Baselines.""" + result = self._post("/v1/baseline", {}) + return pd.DataFrame(result["response"]["baselines"]) + def baseline_export(self, baselineid) -> bytes: + """Return Baseline XML as bytes to save to file""" + payload = {"baselineid": baselineid} + result = self._post_raw("/v1/baseline/export", payload) + return result + # Blocklist Managment + def blocklist_find_all(self) -> pd.DataFrame: + """Retrieve all Baselines.""" + result = self._post("/v1/blocklist", {}) + return pd.DataFrame(result["response"]["blocklists"]) + + def blocklist_export(self, blocklistid) -> bytes: + """Return Blocklist XML as bytes to save to file""" + payload = {"blocklistid": blocklistid} + result = self._post_raw("/v1/blocklist/export", payload) + return result # Hash Management def hash_add_to_allowlist(self, applicationid: str, hashes: List[str]) -> dict: From eb1d710d076d41a13b225a003a1a000360fc51a8 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Mon, 17 Nov 2025 16:13:41 -0500 Subject: [PATCH 27/29] Updates to Allowlist Selection --- Loxide.py | 17 ++------ TUI/allowlistselectionscreen.py | 74 ++++++++++++++++++++++++++------- TUI/otpactivityscreen.py | 23 +++++----- 3 files changed, 75 insertions(+), 39 deletions(-) diff --git a/Loxide.py b/Loxide.py index 7fa448c..fae9cc8 100644 --- a/Loxide.py +++ b/Loxide.py @@ -23,14 +23,13 @@ import logging import os -import tempfile -import dotenv import urllib3 from services.API import AirlockAPIWrapper from services.security import getAPI from TUI.TUI import run_Loxide +from utils.configmanager import get_system_value from utils.setup import get_base_directory, setup from utils.utils import irtang @@ -38,24 +37,14 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) def main(): - - if "NUITKA_ONEFILE_PARENT" in os.environ: - splash_filename = os.path.join( - tempfile.gettempdir(), - f"onefile_{int(os.environ['NUITKA_ONEFILE_PARENT'])}_splash_feedback.tmp", - ) - if os.path.exists(splash_filename): - os.unlink(splash_filename) - irtang() # Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored setup() base_dir = get_base_directory() logger = logging.getLogger(__name__) - dotenv.load_dotenv(dotenv_path=base_dir / ".env") try: - url = os.getenv("URL") + url = get_system_value("URL") username = os.getenv("USERNAME") if not url: @@ -75,7 +64,7 @@ def main(): raise ValueError("API key for Loxide is missing.") api = AirlockAPIWrapper( - base_url=str(os.getenv("URL")), + base_url=str(url), api_key=api_key, ) run_Loxide(api) diff --git a/TUI/allowlistselectionscreen.py b/TUI/allowlistselectionscreen.py index fd84f20..10cf011 100644 --- a/TUI/allowlistselectionscreen.py +++ b/TUI/allowlistselectionscreen.py @@ -32,7 +32,7 @@ class AllowlistSelectionWidget(Static): layout: vertical; } #allowlist_main { - height: 100%; + height: 1fr; width: 100%; } #left_panel { @@ -61,7 +61,8 @@ class AllowlistSelectionWidget(Static): margin: 1 0; } #action_buttons { - height: 10%; + height: auto; + min-height: 3; padding: 1; content-align: center middle; } @@ -149,7 +150,7 @@ class AllowlistSelectionWidget(Static): # Action buttons at bottom with Horizontal(id="action_buttons"): - self.back_btn = Button("← Back", id="back_btn") + self.back_btn = Button("β¬… Back", id="back_btn") self.add_btn = Button("βž• Add to Allowlist", id="add_to_allowlist_btn") self.back_btn.styles.width = "50%" @@ -176,7 +177,8 @@ class AllowlistSelectionWidget(Static): # First, try to get the host's policy if hostname is provided host_policy_allowlists = [] host_policy_ids = set() - policy_name = None + policy_name = "Unknown Policy" # Default value + group_id = None if self.hostname: try: @@ -185,8 +187,23 @@ class AllowlistSelectionWidget(Static): if not agents_df.empty: # Get the policy group ID for this host group_id = agents_df.iloc[0].get("groupid") - policy_name = agents_df.iloc[0].get( - "groupname", "Unknown Policy" + + # Look up the policy name from app's cached policies + if ( + group_id + and hasattr(self.app, "policies") + and self.app.policies + ): + for policy in self.app.policies: + if policy.groupid == group_id: + policy_name = policy.name + logger.info( + f"Found policy name: '{policy_name}' for group_id: {group_id}" + ) + break + + logger.info( + f"Found host '{self.hostname}' in policy '{policy_name}' (group_id: {group_id})" ) if group_id: @@ -208,6 +225,33 @@ class AllowlistSelectionWidget(Static): except Exception as e: logger.warning(f"Could not get host's policy allowlists: {e}") + # If we still don't have a policy name, try to get it from the first allowlist or use a default + if not policy_name: + # Get all policies and try to find which one has allowlists + try: + all_policies_df = self.api.policy_find_all() + if not all_policies_df.empty: + # If we have a group_id from somewhere, use it + if group_id: + policy_row = all_policies_df[ + all_policies_df["groupid"] == group_id + ] + if not policy_row.empty: + policy_name = policy_row.iloc[0].get( + "groupname", "Unknown Policy" + ) + else: + # Use the first policy as fallback + policy_name = all_policies_df.iloc[0].get( + "groupname", "Default Policy" + ) + logger.info(f"Using first available policy: {policy_name}") + else: + policy_name = "Unknown Policy" + except Exception as e: + logger.warning(f"Could not fetch policies: {e}") + policy_name = "Unknown Policy" + # Get all allowlists all_allowlists_df = self.api.allowlist_find_all() @@ -243,7 +287,7 @@ class AllowlistSelectionWidget(Static): # Add policy-associated allowlists if any if host_policy_allowlists: # Add section header - header_text = f"━━━ Policy: {policy_name or 'Host Policy'} ━━━" + header_text = f"=== Policy: {policy_name or 'Host Policy'} ===" self.allowlist_table.add_row(header_text, "", "", key="header_policy") current_row += 1 @@ -270,7 +314,7 @@ class AllowlistSelectionWidget(Static): current_row += 1 self.allowlist_table.add_row( - "━━━ Other Available Allowlists ━━━", "", "", key="header_other" + "=== Other Available Allowlists ===", "", "", key="header_other" ) current_row += 1 @@ -336,9 +380,9 @@ class AllowlistSelectionWidget(Static): if found_col: self.hash_column = found_col - preview_lines.append(f"βœ“ Found hash column: **{found_col}**\n") + preview_lines.append(f"ΓƒΒ’Γ…Β“Γ’Β€Βœ Found hash column: **{found_col}**\n") else: - preview_lines.append("⚠️ **No hash column found**\n") + preview_lines.append("Òő ï¸ **No hash column found**\n") preview_lines.append("Available columns:\n") for col in self.selected_data.columns: if col != "_row_id": @@ -420,7 +464,7 @@ class AllowlistSelectionWidget(Static): self.selected_allowlist = self.allowlists[actual_allowlist_index] self.add_btn.disabled = False self.add_btn.label = ( - f"βž• Add to '{self.selected_allowlist.get('name', 'Unknown')}'" + f"Γ’ΒžΒ• Add to '{self.selected_allowlist.get('name', 'Unknown')}'" ) # Update preview with selection @@ -523,7 +567,7 @@ class AllowlistSelectionWidget(Static): # Success notification self.app.notify( - f"βœ… Successfully added {len(self.hashes_to_add)} hashes to '{allowlist_name}'", + f"Γ’ΒœΒ… Successfully added {len(self.hashes_to_add)} hashes to '{allowlist_name}'", title="Success", severity="information", timeout=5, @@ -531,7 +575,7 @@ class AllowlistSelectionWidget(Static): # Update preview to show success self.preview_area.text = ( - f"## βœ… SUCCESS\n\n" + f"## Γ’ΒœΒ… SUCCESS\n\n" f"Added **{len(self.hashes_to_add)} hashes** to allowlist:\n" f"**{allowlist_name}** (ID: {app_id})\n\n" f"### Operation Details:\n" @@ -551,7 +595,7 @@ class AllowlistSelectionWidget(Static): except Exception as exc: logger.exception(f"Failed to add hashes to allowlist: {exc}") self.app.notify( - f"❌ Failed to add hashes: {str(exc)}", + f"ҝŒ Failed to add hashes: {str(exc)}", title="Error", severity="error", timeout=10, @@ -559,7 +603,7 @@ class AllowlistSelectionWidget(Static): # Re-enable button self.add_btn.disabled = False - self.add_btn.label = "βž• Retry Add to Allowlist" + self.add_btn.label = "Γ’ΒžΒ• Retry Add to Allowlist" class AllowlistSelectionScreen(Screen): diff --git a/TUI/otpactivityscreen.py b/TUI/otpactivityscreen.py index 84847e0..5947cee 100644 --- a/TUI/otpactivityscreen.py +++ b/TUI/otpactivityscreen.py @@ -56,12 +56,13 @@ class OTPActivitiesWidget(Static): layout: vertical; } #activity_preview_container { - height: 75%; + height: 1fr; border: none; padding: 1 1; } #activity_buttons { - height: 25%; + height: auto; + min-height: 3; padding: 1 1; content-align: center middle; } @@ -481,16 +482,18 @@ class ActivityDetailWidget(Static): layout: vertical; } #detail_table_container { - height: 75%; + height: 1fr; padding: 1 1; } #selection_buttons { - height: 10%; + height: auto; + min-height: 3; padding: 1 1; content-align: center middle; } #detail_buttons { - height: 15%; + height: auto; + min-height: 3; padding: 1 1; content-align: center middle; } @@ -649,7 +652,7 @@ class ActivityDetailWidget(Static): logger.exception("Failed to sort by column %s: %s", column_key, exc) return - # βœ… Only refresh rows, not columns + # Òœ… Only refresh rows, not columns await self._build_table(rebuild=False) async def on_button_pressed(self, event) -> None: @@ -728,13 +731,13 @@ class ActivityDetailWidget(Static): if self.activities_df is None or self.activities_df.empty: logger.info("No activities to export.") await self.mount( - Static("❌ No activities to export.", classes="notification") + Static("ҝŒ No activities to export.", classes="notification") ) return if not self.selected_row_ids: logger.info("No rows selected for export.") await self.mount( - Static("❌ No rows selected for export.", classes="notification") + Static("ҝŒ No rows selected for export.", classes="notification") ) return try: @@ -755,12 +758,12 @@ class ActivityDetailWidget(Static): logger.exception("Failed to export detail activities: %s", exc) await self.mount( Static( - "❌ Failed to export activities; check logs.", + "ҝŒ Failed to export activities; check logs.", classes="notification", ) ) - # βœ… Helper methods + # Òœ… Helper methods def get_selected_data(self) -> pd.DataFrame: """Return a DataFrame of the selected rows.""" if not self.selected_row_ids: From 1f06404a1632ceee4cb184a0ff6751a30fe5fed4 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Mon, 17 Nov 2025 16:41:32 -0500 Subject: [PATCH 28/29] Fix Unicode --- TUI/allowlistselectionscreen.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/TUI/allowlistselectionscreen.py b/TUI/allowlistselectionscreen.py index 10cf011..72426f2 100644 --- a/TUI/allowlistselectionscreen.py +++ b/TUI/allowlistselectionscreen.py @@ -567,7 +567,7 @@ class AllowlistSelectionWidget(Static): # Success notification self.app.notify( - f"Γ’ΒœΒ… Successfully added {len(self.hashes_to_add)} hashes to '{allowlist_name}'", + f"βœ… Successfully added {len(self.hashes_to_add)} hashes to '{allowlist_name}'", title="Success", severity="information", timeout=5, @@ -575,7 +575,7 @@ class AllowlistSelectionWidget(Static): # Update preview to show success self.preview_area.text = ( - f"## Γ’ΒœΒ… SUCCESS\n\n" + f"## βœ… SUCCESS\n\n" f"Added **{len(self.hashes_to_add)} hashes** to allowlist:\n" f"**{allowlist_name}** (ID: {app_id})\n\n" f"### Operation Details:\n" @@ -586,16 +586,13 @@ class AllowlistSelectionWidget(Static): ) # Change button to "Done" - self.add_btn.label = "βœ… Done - Close" - self.add_btn.disabled = False - - # When clicked again, close the screen - self.add_btn_success = True + self.add_btn.label = "βœ… Done" + self.add_btn.disabled = True except Exception as exc: logger.exception(f"Failed to add hashes to allowlist: {exc}") self.app.notify( - f"ҝŒ Failed to add hashes: {str(exc)}", + f"❌ Failed to add hashes: {str(exc)}", title="Error", severity="error", timeout=10, @@ -603,7 +600,7 @@ class AllowlistSelectionWidget(Static): # Re-enable button self.add_btn.disabled = False - self.add_btn.label = "Γ’ΒžΒ• Retry Add to Allowlist" + self.add_btn.label = "⟳ Retry Add to Allowlist" class AllowlistSelectionScreen(Screen): From d0fc34fdc73a39349f9c607d64c5925d3040eb97 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Mon, 17 Nov 2025 17:40:46 -0500 Subject: [PATCH 29/29] Changed attribute name for better logging purposes --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/services.rs | 2 +- requirements.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index e9ed8c8..fb2d657 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "3.1.1" +version = "3.1.2" dependencies = [ "chrono", "indicatif", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 5d5ab76..1fb0612 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "3.1.1" +version = "3.1.2" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 044f251..b646277 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "3.1.1" +version = "3.1.2" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index c0f7d21..c38adeb 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -157,7 +157,7 @@ pub fn pull_policy_exec_histories( &client, ); cx.span().set_attribute(KeyValue::new( - "Items in Response", + "items_in_response", results.response.exechistories.len().to_string(), )); results diff --git a/requirements.txt b/requirements.txt index 794b24a..a89fe9e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==3.1.1 \ No newline at end of file +airlock_libs==3.1.2 \ No newline at end of file